release: publish Twitch VOD Manager 1.0.5

This commit is contained in:
Sucukdeluxe
2026-08-11 21:06:37 +02:00
parent 74ebcd895c
commit aa53fcf7e8
27 changed files with 6501 additions and 349 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 1.0.5 - 2026-08-11
- Added a complete local video editor for MP4, M4V, MOV, WebM, MKV, TS and AVI files with frame-accurate trimming, removable ranges, undo and redo, timeline zoom and atomic exports.
- Added a responsive desktop player with smooth scrubbing, keyboard controls, volume interaction, fullscreen playback and synchronized playback state.
- Added high-resolution video thumbnails and a reusable waveform timeline that remain sharp across zoom levels without blocking the first usable view.
- Added precise timeline handles, mouse-wheel zoom anchored to the pointer and smooth navigation for short and long recordings.
- Added safe source validation, cancellable exports and protection against partial or overwritten output files.
- Added a confirmation step before replacing an active edit and reset playback controls correctly when another video is opened.
- Improved editor layout, timestamp readability, metadata alignment, action contrast and language-selection contrast across supported window sizes.
## 1.0.4 - 2026-08-11 ## 1.0.4 - 2026-08-11
- Added smooth entrance and exit motion for the VOD selection action dock, including Windows systems with reduced animations enabled. - Added smooth entrance and exit motion for the VOD selection action dock, including Windows systems with reduced animations enabled.
+3 -2
View File
@@ -33,7 +33,8 @@ The application works in public mode without a Twitch login. Connecting a Twitch
### Download and process ### Download and process
- Download complete VODs or selected time ranges - Download complete VODs or selected time ranges
- Trim, split and merge recordings with dedicated tools - Edit local videos with frame-accurate trimming, removable ranges, timeline zoom, waveform guidance and undo or redo
- Split and merge recordings with dedicated tools
- Queue multiple jobs and follow real progress, speed and remaining time - Queue multiple jobs and follow real progress, speed and remaining time
- Pause and continue an active download without restarting it - Pause and continue an active download without restarting it
- Save optional chat replays and stream events alongside recordings - Save optional chat replays and stream events alongside recordings
@@ -60,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch
## Installation ## Installation
1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
2. Download `Twitch-VOD-Manager-Setup-1.0.4.exe`. 2. Download `Twitch-VOD-Manager-Setup-1.0.5.exe`.
3. Run the installer and choose the installation directory. 3. Run the installer and choose the installation directory.
4. Start Twitch VOD Manager and add a streamer. 4. Start Twitch VOD Manager and add a streamer.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.4", "version": "1.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.4", "version": "1.0.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "1.0.4", "version": "1.0.5",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@@ -20,9 +20,10 @@
"test:e2e:guide": "node scripts/smoke-test-template-guide.js", "test:e2e:guide": "node scripts/smoke-test-template-guide.js",
"test:e2e:full": "node scripts/smoke-test-full.js", "test:e2e:full": "node scripts/smoke-test-full.js",
"test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js", "test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js",
"test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js",
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave",
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
"pack": "npm run build && electron-builder --dir", "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
+1 -1
View File
@@ -83,7 +83,7 @@ if (process.platform === 'win32') {
sourcePath: electronSourceExecutable, sourcePath: electronSourceExecutable,
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
iconPath: resolve(rootDirectory, 'build', 'icon.ico'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
version: '1.0.4', version: '1.0.5',
}); });
} }
+6
View File
@@ -15,6 +15,7 @@
"scripts/capture-readme-screenshot.js", "scripts/capture-readme-screenshot.js",
"scripts/dev.mjs", "scripts/dev.mjs",
"scripts/public-release-files.json", "scripts/public-release-files.json",
"scripts/smoke-test-cutter.js",
"scripts/smoke-test-e2e-isolation-contract.js", "scripts/smoke-test-e2e-isolation-contract.js",
"scripts/smoke-test-full.js", "scripts/smoke-test-full.js",
"scripts/smoke-test-merge-split-logic.js", "scripts/smoke-test-merge-split-logic.js",
@@ -31,6 +32,8 @@
"src/main/domain/chunk-index-store.ts", "src/main/domain/chunk-index-store.ts",
"src/main/domain/config-normalize.test.ts", "src/main/domain/config-normalize.test.ts",
"src/main/domain/config-normalize.ts", "src/main/domain/config-normalize.ts",
"src/main/domain/cutter-export.test.ts",
"src/main/domain/cutter-export.ts",
"src/main/domain/app-identity.test.ts", "src/main/domain/app-identity.test.ts",
"src/main/domain/app-identity.ts", "src/main/domain/app-identity.ts",
"src/main/domain/i18n-backend.test.ts", "src/main/domain/i18n-backend.test.ts",
@@ -55,6 +58,8 @@
"src/main/domain/twitch-oauth.ts", "src/main/domain/twitch-oauth.ts",
"src/main/domain/update-version-utils.test.ts", "src/main/domain/update-version-utils.test.ts",
"src/main/domain/update-version-utils.ts", "src/main/domain/update-version-utils.ts",
"src/main/domain/video-editor.test.ts",
"src/main/domain/video-editor.ts",
"src/main/index.ts", "src/main/index.ts",
"src/main/infra/chunk-hash.test.ts", "src/main/infra/chunk-hash.test.ts",
"src/main/infra/chunk-hash.ts", "src/main/infra/chunk-hash.ts",
@@ -79,6 +84,7 @@
"src/preload.ts", "src/preload.ts",
"src/renderer-archive.ts", "src/renderer-archive.ts",
"src/renderer-command-palette.ts", "src/renderer-command-palette.ts",
"src/renderer-cutter.ts",
"src/renderer-globals.d.ts", "src/renderer-globals.d.ts",
"src/renderer-locale-de.ts", "src/renderer-locale-de.ts",
"src/renderer-locale-en.ts", "src/renderer-locale-en.ts",
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -14,9 +14,9 @@ function check(condition, message) {
if (!condition) failures.push(message); if (!condition) failures.push(message);
} }
check(packageJson.version === '1.0.4', `package version is ${packageJson.version}`); check(packageJson.version === '1.0.5', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.4', `lockfile version is ${packageLock.version}`); check(packageLock.version === '1.0.5', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.4', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageLock.packages?.['']?.version === '1.0.5', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
@@ -45,7 +45,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
check(indexSource.includes('Version: v1.0.4'), 'initial version label is not 1.0.4'); check(indexSource.includes('Version: v1.0.5'), 'initial version label is not 1.0.5');
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
check(fs.existsSync(manifestPath), 'public release manifest is missing'); check(fs.existsSync(manifestPath), 'public release manifest is missing');
+42 -13
View File
@@ -96,6 +96,31 @@ async function run() {
check(shell.topNavigationItems === 7, `Expected 7 native primary navigation buttons, found ${shell.topNavigationItems}`); check(shell.topNavigationItems === 7, `Expected 7 native primary navigation buttons, found ${shell.topNavigationItems}`);
check(shell.nonButtonNavigationItems === 0, `Expected only native primary navigation buttons, found ${shell.nonButtonNavigationItems} non-buttons`); check(shell.nonButtonNavigationItems === 0, `Expected only native primary navigation buttons, found ${shell.nonButtonNavigationItems} non-buttons`);
const mergeAddToolbarActions = await win.evaluate(() => {
const capture = () => {
const button = document.querySelector('[data-toolbar-for="merge"] button[onclick="addMergeFiles()"]');
const label = button?.querySelector('span')?.textContent?.trim() || '';
const accessibleLabel = button?.getAttribute('aria-label')?.trim() || label;
return {
label,
accessibleLabel,
plusCount: (button?.querySelectorAll('svg').length || 0) + ((button?.textContent?.match(/\+/g) || []).length)
};
};
window.changeLanguage('en');
const english = capture();
window.changeLanguage('de');
const german = capture();
window.changeLanguage('en');
return { english, german };
});
checks.mergeAddToolbarActions = mergeAddToolbarActions;
check(mergeAddToolbarActions.english.plusCount === 1, `English Add videos toolbar action exposes ${mergeAddToolbarActions.english.plusCount} plus signs`);
check(mergeAddToolbarActions.german.plusCount === 1, `German Videos hinzufügen toolbar action exposes ${mergeAddToolbarActions.german.plusCount} plus signs`);
check(mergeAddToolbarActions.english.label === 'Add videos' && mergeAddToolbarActions.english.accessibleLabel === 'Add videos', `English Add videos toolbar label is "${mergeAddToolbarActions.english.accessibleLabel}"`);
check(mergeAddToolbarActions.german.label === 'Videos hinzufügen' && mergeAddToolbarActions.german.accessibleLabel === 'Videos hinzufügen', `German Videos hinzufügen toolbar label is "${mergeAddToolbarActions.german.accessibleLabel}"`);
await app.evaluate(({ ipcMain }) => { await app.evaluate(({ ipcMain }) => {
globalThis.__workspaceStreamerCacheCalls = { ids: 0, vods: 0, profiles: 0 }; globalThis.__workspaceStreamerCacheCalls = { ids: 0, vods: 0, profiles: 0 };
ipcMain.removeHandler('get-user-id'); ipcMain.removeHandler('get-user-id');
@@ -152,16 +177,16 @@ async function run() {
const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4'); const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4');
fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8'); fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8');
await app.evaluate(({ ipcMain }) => { await app.evaluate(({ ipcMain }) => {
globalThis.__workspaceCutterDropPaths = { videoInfo: '', preview: '' }; globalThis.__workspaceCutterDropPaths = { media: '' };
ipcMain.removeHandler('get-video-info'); ipcMain.removeHandler('prepare-video-editor-media');
ipcMain.handle('get-video-info', (_, filePath) => { ipcMain.handle('prepare-video-editor-media', (_, filePath) => {
globalThis.__workspaceCutterDropPaths.videoInfo = filePath; globalThis.__workspaceCutterDropPaths.media = filePath;
return { duration: 120, width: 1920, height: 1080, fps: 60 }; return {
}); sourceUrl: encodeURI(`file:///${filePath.replace(/\\/g, '/')}`),
ipcMain.removeHandler('extract-frame'); info: { duration: 120, width: 1920, height: 1080, fps: 60, hasAudio: false },
ipcMain.handle('extract-frame', (_, filePath) => { thumbnails: ['data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='],
globalThis.__workspaceCutterDropPaths.preview = filePath; waveform: null
return null; };
}); });
}); });
await win.evaluate(() => { await win.evaluate(() => {
@@ -205,8 +230,7 @@ async function run() {
}; };
check(cutterFileObject.legacyPathType === 'undefined', `Electron File.path is unexpectedly ${cutterFileObject.legacyPathType}`); check(cutterFileObject.legacyPathType === 'undefined', `Electron File.path is unexpectedly ${cutterFileObject.legacyPathType}`);
check(cutterDropUi.filePath === cutterDropFixturePath, `Cutter drop resolved "${cutterDropUi.filePath}" instead of the Electron file path`); check(cutterDropUi.filePath === cutterDropFixturePath, `Cutter drop resolved "${cutterDropUi.filePath}" instead of the Electron file path`);
check(cutterDropPaths.videoInfo === cutterDropFixturePath, `Cutter drop sent "${cutterDropPaths.videoInfo}" to video info instead of the Electron file path`); check(cutterDropPaths.media === cutterDropFixturePath, `Cutter drop sent "${cutterDropPaths.media}" to media preparation instead of the Electron file path`);
check(cutterDropPaths.preview === cutterDropFixturePath, `Cutter drop sent "${cutterDropPaths.preview}" to preview instead of the Electron file path`);
check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls'); check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls');
const queueEmptyActions = await win.evaluate(() => ({ const queueEmptyActions = await win.evaluate(() => ({
@@ -1625,6 +1649,8 @@ async function run() {
const germanLabelBlendAtMiddle = getComputedStyle(document.getElementById('languageDeText')).mixBlendMode; const germanLabelBlendAtMiddle = getComputedStyle(document.getElementById('languageDeText')).mixBlendMode;
await pause(420); await pause(420);
const target = readX(); const target = readX();
const germanLabelColorSelected = getComputedStyle(document.getElementById('languageDeText')).color;
const englishLabelColorInactive = getComputedStyle(document.getElementById('languageEnText')).color;
window.changeLanguage('en'); window.changeLanguage('en');
await pause(150); await pause(150);
const reverseMiddle = readX(); const reverseMiddle = readX();
@@ -1644,6 +1670,8 @@ async function run() {
targetBackgroundAtMiddle, targetBackgroundAtMiddle,
englishLabelBlendAtMiddle, englishLabelBlendAtMiddle,
germanLabelBlendAtMiddle, germanLabelBlendAtMiddle,
germanLabelColorSelected,
englishLabelColorInactive,
transformRuns, transformRuns,
transition: getComputedStyle(picker, '::before').transition transition: getComputedStyle(picker, '::before').transition
}; };
@@ -1659,7 +1687,8 @@ async function run() {
check(languageSwitcherMotion.reverseMiddle > languageMotionMinimum + 1 && languageSwitcherMotion.reverseMiddle < languageMotionMaximum - 1, 'Language marker has no visible reverse intermediate position'); check(languageSwitcherMotion.reverseMiddle > languageMotionMinimum + 1 && languageSwitcherMotion.reverseMiddle < languageMotionMaximum - 1, 'Language marker has no visible reverse intermediate position');
check(Math.abs(languageSwitcherMotion.reverseTarget - languageSwitcherMotion.start) < 1, 'Language marker does not return to English'); check(Math.abs(languageSwitcherMotion.reverseTarget - languageSwitcherMotion.start) < 1, 'Language marker does not return to English');
check(languageSwitcherMotion.targetBackgroundAtMiddle === 'rgba(0, 0, 0, 0)', `Language target paints a second background during motion: ${languageSwitcherMotion.targetBackgroundAtMiddle}`); check(languageSwitcherMotion.targetBackgroundAtMiddle === 'rgba(0, 0, 0, 0)', `Language target paints a second background during motion: ${languageSwitcherMotion.targetBackgroundAtMiddle}`);
check(languageSwitcherMotion.englishLabelBlendAtMiddle === 'difference' && languageSwitcherMotion.germanLabelBlendAtMiddle === 'difference', `Language labels do not adapt continuously to the moving marker: ${languageSwitcherMotion.englishLabelBlendAtMiddle}/${languageSwitcherMotion.germanLabelBlendAtMiddle}`); check(languageSwitcherMotion.englishLabelBlendAtMiddle === 'normal' && languageSwitcherMotion.germanLabelBlendAtMiddle === 'normal', `Language labels still use color-distorting blend modes: ${languageSwitcherMotion.englishLabelBlendAtMiddle}/${languageSwitcherMotion.germanLabelBlendAtMiddle}`);
check(languageSwitcherMotion.germanLabelColorSelected === 'rgb(23, 32, 51)' && languageSwitcherMotion.englishLabelColorInactive === 'rgb(173, 169, 169)', `Selected and inactive language labels do not use the requested contrast: ${languageSwitcherMotion.germanLabelColorSelected}/${languageSwitcherMotion.englishLabelColorInactive}`);
await win.evaluate(() => window.changeLanguage('en')); await win.evaluate(() => window.changeLanguage('en'));
await win.waitForTimeout(460); await win.waitForTimeout(460);
await win.evaluate(() => window.changeLanguage('de')); await win.evaluate(() => window.changeLanguage('de'));
+156 -38
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:;"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' file: blob:;">
<title>Twitch VOD Manager</title> <title>Twitch VOD Manager</title>
<link rel="stylesheet" href="./styles.css"> <link rel="stylesheet" href="./styles.css">
<link rel="stylesheet" href="./workspace.css"> <link rel="stylesheet" href="./workspace.css">
@@ -36,6 +36,17 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="cutterDiscardModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="cutterDiscardTitle" onclick="handleCutterDiscardOverlayClick(event)" onkeydown="trapCutterDiscardFocus(event)">
<div class="modal cutter-discard-modal">
<h2 id="cutterDiscardTitle">Aktuellen Schnitt verwerfen?</h2>
<p id="cutterDiscardMessage">Beim Öffnen eines anderen Videos gehen die aktuellen Schnittänderungen verloren.</p>
<div class="modal-actions">
<button class="btn-secondary" id="cutterDiscardCancelBtn" type="button" onclick="resolveCutterDiscard(false)">Aktuelles Video behalten</button>
<button class="btn-danger" id="cutterDiscardConfirmBtn" type="button" onclick="resolveCutterDiscard(true)">Verwerfen und öffnen</button>
</div>
</div>
</div>
<!-- Clip Dialog Modal --> <!-- Clip Dialog Modal -->
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle"> <div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
<div class="modal clip-modal"> <div class="modal clip-modal">
@@ -436,54 +447,163 @@
<!-- Video Cutter Tab --> <!-- Video Cutter Tab -->
<div class="tab-content" id="cutterTab"> <div class="tab-content" id="cutterTab">
<div class="cutter-container"> <div class="cutter-container">
<div class="settings-card"> <div class="cutter-source-bar">
<h3 id="cutterSelectTitle">Video auswählen</h3> <div class="cutter-source-copy">
<div class="form-row"> <span class="cutter-source-title" id="cutterSelectTitle">Video auswählen</span>
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt..."> <input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt">
</div>
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button> <button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
</div> </div>
</div>
<div class="cutter-workspace" id="cutterWorkspace">
<aside class="cutter-sidebar">
<div class="cutter-sidebar-heading">
<div>
<span class="cutter-eyebrow">EDITOR</span>
<h3 id="cutterEditHeading">Trimmen & schneiden</h3>
</div>
<button type="button" class="cutter-icon-button" id="cutterNewCutBtn" onclick="addCutterCut()" disabled aria-label="Neuer Schnitt" title="Neuer Schnitt">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg>
</button>
</div>
<label class="cutter-preview-toggle">
<span>
<strong id="cutterPreviewModeLabel">Schnittvorschau</strong>
<small id="cutterPreviewModeHint">Entfernte Bereiche überspringen</small>
</span>
<input type="checkbox" id="cutterPreviewMode" checked onchange="setCutterPreviewMode(this.checked)">
<span class="cutter-toggle-track" aria-hidden="true"></span>
</label>
<div class="cutter-trim-card">
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
<div class="cutter-time-field-row">
<label for="startTime" id="cutterStartLabel">Start</label>
<input type="text" id="startTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()">
</div>
<div class="cutter-time-field-row">
<label for="endTime" id="cutterEndLabel">Ende</label>
<input type="text" id="endTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()">
</div>
</div>
<div class="cutter-cut-section">
<div class="cutter-card-title-row">
<span class="cutter-card-title" id="cutterCutsLabel">Entfernte Bereiche</span>
<span class="cutter-cut-count" id="cutterCutCount">0</span>
</div>
<div class="cutter-cut-list" id="cutterCutList">
<div class="cutter-cut-empty" id="cutterCutEmpty">Noch keine Schnitte</div>
</div>
</div>
</aside>
<section class="cutter-preview-panel">
<div class="video-preview" id="cutterPreview"> <div class="video-preview" id="cutterPreview">
<div class="placeholder"> <video id="cutterVideo" preload="metadata"></video>
<div class="placeholder" id="cutterPreviewEmpty">
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg> <svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p id="cutterPreviewPlaceholder">Video auswählen um Vorschau zu sehen</p> <p id="cutterPreviewPlaceholder">Video auswählen, um eine Vorschau zu sehen</p>
</div>
<div class="cutter-player-loading" id="cutterPlayerLoading" hidden>
<span class="cutter-spinner"></span>
<span id="cutterLoadingLabel">Video wird vorbereitet…</span>
</div>
<div class="cutter-player-controls" id="cutterPlayerControls">
<button type="button" class="cutter-player-button" id="cutterPlayBtn" onclick="toggleCutterPlayback()" disabled aria-label="Abspielen">
<svg class="cutter-play-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
<svg class="cutter-pause-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M7 5h4v14H7zM13 5h4v14h-4z"></path></svg>
</button>
<button type="button" class="cutter-player-button" id="cutterStopBtn" onclick="stopCutterPlayback()" disabled data-cutter-media-control aria-label="Stopp">
<svg class="cutter-filled-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M7 7h10v10H7z"></path></svg>
</button>
<button type="button" class="cutter-player-button cutter-skip-button" id="cutterRewindBtn" onclick="skipCutterPlayback(-10)" disabled data-cutter-media-control aria-label="10 Sekunden zurück">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5.2 8.2A8 8 0 1 1 4 14.5"></path><path d="M5 4v5h5"></path></svg><span>10</span>
</button>
<button type="button" class="cutter-player-button cutter-skip-button" id="cutterForwardBtn" onclick="skipCutterPlayback(10)" disabled data-cutter-media-control aria-label="10 Sekunden vor">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M18.8 8.2A8 8 0 1 0 20 14.5"></path><path d="M19 4v5h-5"></path></svg><span>10</span>
</button>
<div class="cutter-volume-control">
<button type="button" class="cutter-player-button" id="cutterMuteBtn" onclick="toggleCutterMute()" disabled aria-label="Ton umschalten">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4 4 0 0 0-2-3.46v6.92A4 4 0 0 0 16.5 12z"></path></svg>
</button>
<input type="range" class="cutter-volume" id="cutterVolume" min="0" max="1" step="0.05" value="1" disabled aria-label="Lautstärke">
</div>
<span class="cutter-player-time"><span id="cutterCurrentTime">00:00:00</span><span>/</span><span id="cutterTotalTime">00:00:00</span></span>
<select id="cutterPlaybackRate" hidden disabled aria-label="Wiedergabegeschwindigkeit">
<option value="0.5">0,5×</option>
<option value="0.75">0,75×</option>
<option value="1" selected>1×</option>
<option value="1.25">1,25×</option>
<option value="1.5">1,5×</option>
<option value="2">2×</option>
</select>
<div class="cutter-player-settings">
<button type="button" class="cutter-player-button" id="cutterSettingsBtn" onclick="toggleCutterSettingsMenu()" disabled aria-label="Einstellungen" aria-expanded="false">
<svg class="cutter-filled-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19.4 13a7.8 7.8 0 0 0 .1-1 7.8 7.8 0 0 0-.1-1l2.1-1.6-2-3.4-2.5 1a8 8 0 0 0-1.7-1L15 3.3h-4L10.6 6a8 8 0 0 0-1.7 1L6.4 6l-2 3.4L6.5 11a7.8 7.8 0 0 0-.1 1 7.8 7.8 0 0 0 .1 1l-2.1 1.6 2 3.4 2.5-1a8 8 0 0 0 1.7 1l.4 2.7h4l.4-2.7a8 8 0 0 0 1.7-1l2.5 1 2-3.4L19.4 13zM13 15.5A3.5 3.5 0 1 1 13 8a3.5 3.5 0 0 1 0 7.5z"></path></svg>
</button>
<div class="cutter-settings-menu" id="cutterSettingsMenu" hidden>
<span id="cutterSpeedLabel">Geschwindigkeit</span>
<div class="cutter-speed-options">
<button type="button" data-rate="0.5" onclick="setCutterPlaybackRate(0.5)">0,5×</button>
<button type="button" data-rate="0.75" onclick="setCutterPlaybackRate(0.75)">0,75×</button>
<button type="button" class="active" data-rate="1" onclick="setCutterPlaybackRate(1)">Normal</button>
<button type="button" data-rate="1.25" onclick="setCutterPlaybackRate(1.25)">1,25×</button>
<button type="button" data-rate="1.5" onclick="setCutterPlaybackRate(1.5)">1,5×</button>
<button type="button" data-rate="2" onclick="setCutterPlaybackRate(2)">2×</button>
</div>
</div>
</div>
<button type="button" class="cutter-player-button" id="cutterFullscreenBtn" onclick="toggleCutterFullscreen()" disabled aria-label="Vollbild">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"></path></svg>
</button>
</div> </div>
</div> </div>
<div class="cutter-info" id="cutterInfo"> <div class="cutter-info" id="cutterInfo">
<div class="cutter-info-item"> <div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span><span class="cutter-info-value" id="infoDuration">--:--:--</span></div>
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span> <div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span><span class="cutter-info-value" id="infoResolution">----×----</span></div>
<span class="cutter-info-value" id="infoDuration">--:--:--</span> <div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span><span class="cutter-info-value" id="infoFps">--</span></div>
</div> <div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoSelectionLabel">Ausgabe</span><span class="cutter-info-value" id="infoSelection">--:--:--</span></div>
<div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span>
<span class="cutter-info-value" id="infoResolution">----x----</span>
</div>
<div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
<span class="cutter-info-value" id="infoFps">--</span>
</div>
<div class="cutter-info-item">
<span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
</div> </div>
</section>
</div> </div>
<div class="timeline-container" id="timelineContainer"> <div class="timeline-container" id="timelineContainer">
<div class="timeline" id="timeline" onclick="seekTimeline(event)"> <div class="cutter-timeline-toolbar">
<div class="timeline-selection" id="timelineSelection"></div> <div class="cutter-timeline-timecode" id="cutterTimelineTimecode">00:00:00</div>
<div class="timeline-current" id="timelineCurrent"></div> <div class="cutter-history-controls">
<button type="button" class="cutter-icon-button" id="cutterUndoBtn" onclick="undoCutterEdit()" disabled aria-label="Rückgängig" title="Rückgängig (Strg+Z)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5v-3h5a5 5 0 0 1 5 5v1h2v-1a7 7 0 0 0-7-7H9V7z"></path></svg></button>
<button type="button" class="cutter-icon-button" id="cutterRedoBtn" onclick="redoCutterEdit()" disabled aria-label="Wiederholen" title="Wiederholen (Strg+Y)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5v-3h-5a5 5 0 0 0-5 5v1H3v-1a7 7 0 0 1 7-7h5V7z"></path></svg></button>
</div> </div>
<div class="cutter-zoom-controls">
<div class="time-inputs"> <button type="button" class="cutter-icon-button" id="cutterZoomOutBtn" onclick="changeCutterZoom(-0.25)" disabled aria-label="Verkleinern"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"></path></svg></button>
<div class="time-input-group"> <input type="range" id="cutterZoom" min="1" max="16" step="0.05" value="1" disabled aria-label="Timeline-Zoom">
<label id="cutterStartLabel" for="startTime">Start:</label> <button type="button" class="cutter-icon-button" id="cutterZoomInBtn" onclick="changeCutterZoom(0.25)" disabled aria-label="Vergrößern"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg></button>
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
</div> </div>
<div class="time-input-group"> <div class="cutter-actions">
<label id="cutterEndLabel" for="endTime">Ende:</label> <button type="button" class="btn-secondary" id="cutterCancelExportBtn" onclick="cancelCutterExport()" hidden>Abbrechen</button>
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()"> <button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Video exportieren</button>
</div>
</div>
<div class="cutter-timeline-scroll" id="cutterTimelineScroll">
<div class="timeline" id="timeline">
<div class="cutter-ruler" id="cutterRuler"></div>
<div class="cutter-track cutter-video-track" id="cutterVideoTrack">
<span class="cutter-track-label" id="cutterVideoTrackLabel">VIDEO</span>
<div class="cutter-thumbnail-strip" id="cutterThumbnailStrip"></div>
</div>
<div class="cutter-track cutter-audio-track" id="cutterAudioTrack">
<span class="cutter-track-label" id="cutterAudioTrackLabel">AUDIO</span>
<img id="cutterWaveform" alt="" draggable="false">
<div class="cutter-audio-empty" id="cutterAudioEmpty">Keine Audiospur</div>
</div>
<div class="cutter-outside-shade cutter-outside-left" id="cutterOutsideLeft"></div>
<div class="cutter-outside-shade cutter-outside-right" id="cutterOutsideRight"></div>
<div class="timeline-selection" id="timelineSelection">
<button type="button" class="timeline-handle start" id="cutterTrimStartHandle" aria-label="Start verschieben"></button>
<button type="button" class="timeline-handle end" id="cutterTrimEndHandle" aria-label="Ende verschieben"></button>
</div>
<div class="cutter-cut-overlays" id="cutterCutOverlays"></div>
<div class="timeline-current" id="timelineCurrent"><span></span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -495,9 +615,6 @@
<div class="progress-text" id="cutProgressText">0%</div> <div class="progress-text" id="cutProgressText">0%</div>
</div> </div>
<div class="cutter-actions">
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
</div>
</div> </div>
</div> </div>
@@ -783,7 +900,7 @@
<div class="settings-card" data-settings-pane="updates" hidden> <div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.4</p> <p id="versionInfo" class="card-intro">Version: v1.0.5</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
@@ -964,6 +1081,7 @@
<script src="../dist/renderer-profile.js"></script> <script src="../dist/renderer-profile.js"></script>
<script src="../dist/renderer-vod-hover.js"></script> <script src="../dist/renderer-vod-hover.js"></script>
<script src="../dist/renderer-command-palette.js"></script> <script src="../dist/renderer-command-palette.js"></script>
<script src="../dist/renderer-cutter.js"></script>
<script src="../dist/renderer.js"></script> <script src="../dist/renderer.js"></script>
</body> </body>
</html> </html>
+687 -16
View File
@@ -1,8 +1,9 @@
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron'; import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification, type IpcMainInvokeEvent } from 'electron';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
import { connect as tlsConnect, TLSSocket } from 'node:tls'; import { connect as tlsConnect, TLSSocket } from 'node:tls';
import { pathToFileURL } from 'node:url';
import axios from 'axios'; import axios from 'axios';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
@@ -36,6 +37,8 @@ import {
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
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 { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export';
import { import {
setDebugLogFn, initToolDirs, setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
@@ -250,6 +253,49 @@ interface VideoInfo {
width: number; width: number;
height: number; height: number;
fps: number; fps: number;
hasAudio: boolean;
videoCodec: string;
audioCodec: string | null;
previewCompatible: boolean;
variableFrameRate: boolean;
}
interface VideoEditorMedia {
sourceUrl: string;
info: VideoInfo;
jobId: number;
thumbnails: string[];
waveform: string | null;
}
interface VideoEditorAssets {
jobId: number;
thumbnails: string[];
thumbnailSprite: string | null;
thumbnailCount: number;
pixelWidth: number;
pixelHeight: number;
}
interface VideoEditorWaveform {
jobId: number;
waveform: string | null;
pixelWidth: number;
pixelHeight: number;
}
interface VideoEditorAssetProfile {
timelineWidth: number;
trackHeight: number;
pixelRatio: number;
}
interface VideoEditExportRequest {
inputFile: string;
outputFile?: string;
trimStart: number;
trimEnd: number;
cuts: EditorCut[];
} }
interface ReleaseUpdateInfo { interface ReleaseUpdateInfo {
@@ -685,6 +731,31 @@ let queuePaused = false;
// and clip downloads via activeClipProcesses. Keeping these separate // and clip downloads via activeClipProcesses. Keeping these separate
// prevents cancel-download from killing an unrelated cutter ffmpeg. // prevents cancel-download from killing an unrelated cutter ffmpeg.
let currentEditorProcess: ChildProcess | null = null; let currentEditorProcess: ChildProcess | null = null;
let currentCutterProcess: ChildProcess | null = null;
let currentCutterPartialFile: string | null = null;
let cutterExportActive = false;
let cutterExportCancelled = false;
let cutterPreparedInput: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null = null;
let cutterMediaGeneration = 0;
let cutterMediaRequestGeneration = 0;
let cutterAssetRunGeneration = 0;
let cutterWaveformGeneration = 0;
let cutterMediaJob: {
jobId: number;
path: string;
identity: { path: string; size: number; mtimeMs: number; dev: number; ino: number };
info: VideoInfo;
waveform: VideoEditorWaveform | null;
waveformPromise: Promise<VideoEditorWaveform | null> | null;
previewDirectory: string | null;
} | null = null;
let appShutdownStarted = false;
const currentCutterMediaProcesses = new Set<ChildProcess>();
const currentCutterWaveformProcesses = new Set<ChildProcess>();
const currentCutterProbeProcesses = new Set<ChildProcess>();
const currentCutterInfoProcesses = new Set<ChildProcess>();
const currentCutterExportProcesses = new Set<ChildProcess>();
const currentCutterPreviewProcesses = new Set<ChildProcess>();
// Per-item cancellation lives in `cancelledItemIds`. The previous global // Per-item cancellation lives in `cancelledItemIds`. The previous global
// `currentDownloadCancelled` flag was redundant once pause/cancel/remove // `currentDownloadCancelled` flag was redundant once pause/cancel/remove
// started iterating activeDownloads and adding each item to that Set; it // started iterating activeDownloads and adding each item to that Set; it
@@ -2677,7 +2748,20 @@ async function getClipInfo(clipId: string): Promise<any | null> {
// ========================================== // ==========================================
// VIDEO INFO (for cutter) // VIDEO INFO (for cutter)
// ========================================== // ==========================================
async function getVideoInfo(filePath: string): Promise<VideoInfo | null> { function isVideoEditorPreviewCompatible(filePath: string, videoCodec: string, audioCodec: string | null): boolean {
const extension = path.extname(filePath).toLowerCase();
const audioCompatible = !audioCodec || ['aac', 'mp3', 'opus', 'vorbis'].includes(audioCodec);
if (['.mp4', '.m4v', '.mov'].includes(extension)) return ['h264', 'av1', 'vp9'].includes(videoCodec) && audioCompatible;
if (extension === '.webm') return ['vp8', 'vp9', 'av1'].includes(videoCodec) && audioCompatible;
if (extension === '.mkv') return ['h264', 'av1', 'vp8', 'vp9'].includes(videoCodec) && audioCompatible;
return false;
}
function isSupportedVideoEditorInput(filePath: string): boolean {
return ['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi'].includes(path.extname(filePath).toLowerCase());
}
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) {
appendDebugLog('get-video-info-missing-ffmpeg'); appendDebugLog('get-video-info-missing-ffmpeg');
@@ -2695,7 +2779,29 @@ async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
]; ];
const proc = spawn(ffprobe, args, { windowsHide: true }); const proc = spawn(ffprobe, args, { windowsHide: true });
trackedProcesses?.add(proc);
proc.stderr?.resume();
let output = ''; let output = '';
let resolved = false;
let forceResolveTimer: NodeJS.Timeout | null = null;
const resolveOnce = (value: VideoInfo | null): void => {
if (resolved) return;
resolved = true;
resolve(value);
};
const finish = (value: VideoInfo | null): void => {
clearTimeout(timeoutTimer);
if (forceResolveTimer) clearTimeout(forceResolveTimer);
trackedProcesses?.delete(proc);
resolveOnce(value);
};
const timeoutTimer = setTimeout(() => {
try { proc.kill(); } catch { }
forceResolveTimer = setTimeout(() => resolveOnce(null), 2000);
}, timeoutMs);
proc.stdout?.on('data', (data) => { proc.stdout?.on('data', (data) => {
output += data.toString(); output += data.toString();
@@ -2703,29 +2809,447 @@ async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
proc.on('close', (code) => { proc.on('close', (code) => {
if (code !== 0) { if (code !== 0) {
resolve(null); finish(null);
return; return;
} }
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 duration = parseFloat(info.format?.duration || videoStream?.duration || '0');
const averageFps = parseFrameRate(videoStream?.avg_frame_rate);
const realFps = parseFrameRate(videoStream?.r_frame_rate);
const fps = averageFps > 0 ? averageFps : realFps;
resolve({ if (!videoStream || !Number.isFinite(duration) || duration <= 0 || !videoStream.width || !videoStream.height || !Number.isFinite(fps) || fps <= 0) {
duration: parseFloat(info.format?.duration || '0'), finish(null);
width: videoStream?.width || 0, return;
height: videoStream?.height || 0, }
fps: parseFrameRate(videoStream?.r_frame_rate)
const videoCodec = String(videoStream.codec_name || '').toLowerCase();
const audioCodec = audioStream ? String(audioStream.codec_name || '').toLowerCase() : null;
const variableFrameRate = averageFps > 0 && realFps > 0 && Math.abs(averageFps - realFps) / Math.max(averageFps, realFps) > 0.005;
finish({
duration,
width: videoStream.width,
height: videoStream.height,
fps,
hasAudio: Boolean(audioStream),
videoCodec,
audioCodec,
previewCompatible: isVideoEditorPreviewCompatible(filePath, videoCodec, audioCodec),
variableFrameRate,
}); });
} catch { } catch {
resolve(null); finish(null);
} }
}); });
proc.on('error', () => resolve(null)); proc.on('error', () => finish(null));
}); });
} }
function cancelCutterMediaPreparation(): void {
cutterAssetRunGeneration += 1;
for (const process of currentCutterMediaProcesses) {
try { process.kill(); } catch { }
}
}
function cancelCutterMetadataPreparation(): void {
for (const process of currentCutterProbeProcesses) {
try { process.kill(); } catch { }
}
}
function cancelCutterPreviewPreparation(): void {
for (const process of currentCutterPreviewProcesses) {
try { process.kill(); } catch { }
}
}
function cancelCutterWaveformPreparation(): void {
cutterWaveformGeneration += 1;
for (const process of currentCutterWaveformProcesses) {
try { process.kill(); } catch { }
}
}
function runEditorMediaProcess(args: string[], runGeneration: number): Promise<boolean> {
return new Promise((resolve) => {
if (runGeneration !== cutterAssetRunGeneration || appShutdownStarted) {
resolve(false);
return;
}
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
currentCutterMediaProcesses.add(proc);
proc.stderr?.resume();
let settled = false;
const finish = (success: boolean): void => {
if (settled) return;
settled = true;
currentCutterMediaProcesses.delete(proc);
resolve(success && runGeneration === cutterAssetRunGeneration && !appShutdownStarted);
};
proc.on('close', (code) => finish(code === 0));
proc.on('error', () => finish(false));
});
}
function runEditorWaveformProcess(args: string[], runGeneration: number): Promise<boolean> {
return new Promise((resolve) => {
if (runGeneration !== cutterWaveformGeneration || appShutdownStarted) {
resolve(false);
return;
}
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
currentCutterWaveformProcesses.add(proc);
proc.stderr?.resume();
let settled = false;
const finish = (success: boolean): void => {
if (settled) return;
settled = true;
currentCutterWaveformProcesses.delete(proc);
resolve(success && runGeneration === cutterWaveformGeneration && !appShutdownStarted);
};
proc.on('close', (code) => finish(code === 0));
proc.on('error', () => finish(false));
});
}
function runEditorPreviewProcess(args: string[], requestGeneration: number): Promise<boolean> {
return new Promise((resolve) => {
if (requestGeneration !== cutterMediaRequestGeneration || appShutdownStarted) {
resolve(false);
return;
}
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
currentCutterPreviewProcesses.add(proc);
proc.stderr?.resume();
let settled = false;
const finish = (success: boolean): void => {
if (settled) return;
settled = true;
currentCutterPreviewProcesses.delete(proc);
resolve(success && requestGeneration === cutterMediaRequestGeneration && !appShutdownStarted);
};
proc.on('close', (code) => finish(code === 0));
proc.on('error', () => finish(false));
});
}
function removeCutterPreviewDirectory(directory: string | null): void {
if (!directory) return;
try { fs.rmSync(directory, { recursive: true, force: true }); } catch { }
}
function createVideoEditorPreview(filePath: string, info: VideoInfo, requestGeneration: number): Promise<{ sourceUrl: string; directory: string } | null> {
const directory = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-preview-${process.pid}-`));
const previewFile = path.join(directory, 'preview.mp4');
const copyVideo = ['h264', 'av1', 'vp9'].includes(info.videoCodec);
const copyAudio = !info.audioCodec || ['aac', 'mp3'].includes(info.audioCodec);
const args = ['-fflags', '+genpts', '-i', filePath, '-map', '0:v:0', '-map', '0:a:0?'];
if (copyVideo) args.push('-c:v', 'copy');
else args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '20', '-pix_fmt', 'yuv420p');
if (info.hasAudio) {
if (copyAudio) args.push('-c:a', 'copy');
else args.push('-c:a', 'aac', '-b:a', '160k');
} else {
args.push('-an');
}
args.push('-movflags', '+faststart', '-avoid_negative_ts', 'make_zero', '-y', previewFile);
return runEditorPreviewProcess(args, requestGeneration).then((success) => {
if (!success || !fs.existsSync(previewFile) || fs.statSync(previewFile).size <= 256) {
removeCutterPreviewDirectory(directory);
return null;
}
return { sourceUrl: pathToFileURL(previewFile).href, directory };
});
}
function readImageDataUrl(filePath: string): string | null {
if (!fs.existsSync(filePath)) return null;
const extension = path.extname(filePath).toLowerCase();
const mediaType = extension === '.png' ? 'image/png' : 'image/jpeg';
return `data:${mediaType};base64,${fs.readFileSync(filePath).toString('base64')}`;
}
async function prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null> {
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !isSupportedVideoEditorInput(filePath) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null;
const requestGeneration = ++cutterMediaRequestGeneration;
cancelCutterPreviewPreparation();
cancelCutterMetadataPreparation();
const identityBefore = getCutterInputIdentity(filePath);
if (!identityBefore) return null;
const info = await getVideoInfo(filePath, currentCutterProbeProcesses);
const identityAfter = getCutterInputIdentity(filePath);
if (!info || info.variableFrameRate || requestGeneration !== cutterMediaRequestGeneration || !cutterInputIdentitiesMatch(identityBefore, identityAfter) || appShutdownStarted) return null;
const preview = info.previewCompatible
? { sourceUrl: pathToFileURL(filePath).href, directory: null }
: await createVideoEditorPreview(filePath, info, requestGeneration);
if (!preview || requestGeneration !== cutterMediaRequestGeneration || !cutterInputIdentitiesMatch(identityBefore, getCutterInputIdentity(filePath)) || appShutdownStarted) {
removeCutterPreviewDirectory(preview?.directory || null);
return null;
}
cancelCutterMediaPreparation();
cancelCutterWaveformPreparation();
removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null);
const jobId = ++cutterMediaGeneration;
cutterMediaJob = { jobId, path: identityAfter!.path, identity: identityAfter!, info, waveform: null, waveformPromise: null, previewDirectory: preview.directory };
return {
sourceUrl: preview.sourceUrl,
info,
jobId,
thumbnails: [],
waveform: null,
};
}
async function prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null> {
const job = cutterMediaJob;
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !Number.isInteger(jobId) || !job || jobId !== job.jobId || normalizeComparablePath(filePath) !== job.path || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), job.identity)) return null;
if (!job.info.hasAudio) return { jobId, waveform: null, pixelWidth: 32000, pixelHeight: 240 };
if (job.waveform) return job.waveform;
if (job.waveformPromise) return await job.waveformPromise;
const runGeneration = cutterWaveformGeneration;
const promise = (async (): Promise<VideoEditorWaveform | null> => {
const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-waveform-${process.pid}-`));
const waveformFile = path.join(tempDir, 'waveform.png');
try {
const success = await runEditorWaveformProcess([
'-i', filePath,
'-filter_complex', 'aformat=channel_layouts=mono,showwavespic=s=32000x240:colors=white',
'-frames:v', '1',
'-y', waveformFile,
], runGeneration);
if (!success || runGeneration !== cutterWaveformGeneration || cutterMediaJob !== job || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), job.identity) || appShutdownStarted) return null;
const waveform = readImageDataUrl(waveformFile);
if (!waveform) return null;
const result = { jobId, waveform, pixelWidth: 32000, pixelHeight: 240 };
job.waveform = result;
return result;
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})();
job.waveformPromise = promise;
const result = await promise;
if (cutterMediaJob === job && job.waveformPromise === promise) job.waveformPromise = null;
return result;
}
async function prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> {
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !Number.isInteger(jobId) || !profile || !Number.isFinite(profile.timelineWidth) || profile.timelineWidth <= 0 || !Number.isFinite(profile.trackHeight) || profile.trackHeight <= 0 || !Number.isFinite(profile.pixelRatio) || profile.pixelRatio <= 0 || !cutterMediaJob || jobId !== cutterMediaJob.jobId || normalizeComparablePath(filePath) !== cutterMediaJob.path || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity)) return null;
cancelCutterMediaPreparation();
const runGeneration = cutterAssetRunGeneration;
const info = cutterMediaJob.info;
const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-media-${process.pid}-`));
const pixelRatio = Math.min(3, Math.max(1, profile.pixelRatio));
const pixelWidth = info.duration <= 120 ? 32000 : Math.round(Math.min(32000, Math.max(1800, Math.ceil(profile.timelineWidth * pixelRatio))));
const thumbnailCount = info.duration <= 120 ? 200 : Math.round(Math.min(100, Math.max(30, Math.ceil(pixelWidth / 320))));
const thumbnailTileWidth = Math.max(428, Math.ceil(pixelWidth / thumbnailCount / 2) * 2);
const thumbnailTileHeight = 240;
try {
const thumbnailsReady = await runEditorMediaProcess([
'-i', filePath,
'-vf', `fps=${thumbnailCount / info.duration},scale=${thumbnailTileWidth}:${thumbnailTileHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${thumbnailTileWidth}:${thumbnailTileHeight}`,
'-frames:v', String(thumbnailCount),
'-q:v', '2',
'-start_number', '1',
'-y', path.join(tempDir, 'thumb-%03d.jpg'),
], runGeneration);
if (runGeneration !== cutterAssetRunGeneration || !cutterMediaJob || jobId !== cutterMediaJob.jobId || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity) || appShutdownStarted) return null;
if (!thumbnailsReady) return null;
const thumbnails = fs.readdirSync(tempDir)
.filter((name) => /^thumb-\d+\.jpg$/i.test(name))
.sort()
.map((name) => readImageDataUrl(path.join(tempDir, name)))
.filter((value): value is string => Boolean(value));
if (thumbnails.length < Math.floor(thumbnailCount * 0.95)) return null;
return {
jobId,
thumbnails,
thumbnailSprite: null,
thumbnailCount: thumbnails.length,
pixelWidth,
pixelHeight: thumbnailTileHeight,
};
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function getCutterInputIdentity(filePath: string): { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return null;
return { path: normalizeComparablePath(filePath), size: stat.size, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino };
} catch {
return null;
}
}
function cutterInputIdentityMatches(filePath: string): boolean {
const current = getCutterInputIdentity(filePath);
return cutterInputIdentitiesMatch(current, cutterPreparedInput);
}
function cutterInputIdentitiesMatch(
left: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null,
right: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null,
): boolean {
return Boolean(left && right
&& left.path === right.path
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs
&& left.dev === right.dev
&& left.ino === right.ino);
}
function normalizeComparablePath(filePath: string): string {
const resolved = path.resolve(filePath);
let canonical = resolved;
try { canonical = fs.realpathSync.native(resolved); } catch {
try {
const parent = fs.realpathSync.native(path.dirname(resolved));
canonical = path.join(parent, path.basename(resolved));
} catch { }
}
return process.platform === 'win32' ? canonical.toLocaleLowerCase('en-US') : canonical;
}
function pathsReferToSameFile(left: string, right: string): boolean {
if (normalizeComparablePath(left) === normalizeComparablePath(right)) return true;
if (!fs.existsSync(left) || !fs.existsSync(right)) return false;
try {
const leftStat = fs.statSync(left);
const rightStat = fs.statSync(right);
return leftStat.dev === rightStat.dev && leftStat.ino !== 0 && leftStat.ino === rightStat.ino;
} catch {
return false;
}
}
function publishVideoEditorOutput(partialFile: string, outputFile: string): void {
const backupFile = `${outputFile}.${process.pid}.${Date.now()}.tvm-backup`;
const hadExistingOutput = fs.existsSync(outputFile);
if (hadExistingOutput) fs.renameSync(outputFile, backupFile);
try {
fs.renameSync(partialFile, outputFile);
if (hadExistingOutput) {
try { fs.rmSync(backupFile, { force: true }); } catch { }
}
} catch (error) {
if (hadExistingOutput && fs.existsSync(backupFile) && !fs.existsSync(outputFile)) fs.renameSync(backupFile, outputFile);
throw error;
}
}
async function performVideoEditExport(request: VideoEditExportRequest, onProgress: (percent: number) => void): Promise<boolean> {
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 (!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;
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 {
for (const cut of request.cuts) {
state = addCutAt(state, cut.start, cut.end - cut.start).state;
}
} catch {
return false;
}
const segments = getPlayableSegments(state);
if (segments.length === 0) return false;
const outputDir = path.dirname(request.outputFile);
if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) return false;
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 });
if (plan.filterComplex.length > 24000 || cutterExportCancelled) return false;
currentCutterPartialFile = partialFile;
const success = await new Promise<boolean>((resolve) => {
const proc = spawn(getFFmpegPath(), plan.ffmpegArgs, { windowsHide: true });
currentCutterProcess = proc;
currentCutterExportProcesses.add(proc);
proc.stderr?.resume();
let stdout = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
const lines = stdout.split(/\r?\n/);
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));
}
});
proc.on('close', (code) => {
currentCutterExportProcesses.delete(proc);
if (currentCutterProcess === proc) currentCutterProcess = null;
resolve(code === 0 && !cutterExportCancelled);
});
proc.on('error', () => {
currentCutterExportProcesses.delete(proc);
if (currentCutterProcess === proc) currentCutterProcess = null;
resolve(false);
});
});
if (!success || !fs.existsSync(partialFile) || fs.statSync(partialFile).size <= 256) {
fs.rmSync(partialFile, { force: true });
currentCutterPartialFile = null;
return false;
}
if (cutterExportCancelled) {
fs.rmSync(partialFile, { force: true });
currentCutterPartialFile = null;
return false;
}
const outputInfo = await getVideoInfo(partialFile, currentCutterExportProcesses);
if (cutterExportCancelled || !outputInfo || Math.abs(outputInfo.duration - plan.remainingDuration) > Math.max(0.12, 3 / info.fps)) {
fs.rmSync(partialFile, { force: true });
currentCutterPartialFile = null;
return false;
}
if (cutterExportCancelled || pathsReferToSameFile(request.inputFile, request.outputFile) || !cutterInputIdentitiesMatch(getCutterInputIdentity(request.inputFile), inputIdentity)) {
fs.rmSync(partialFile, { force: true });
currentCutterPartialFile = null;
return false;
}
publishVideoEditorOutput(partialFile, request.outputFile);
currentCutterPartialFile = null;
onProgress(100);
return true;
}
async function exportVideoEdit(request: VideoEditExportRequest, onProgress: (percent: number) => void): Promise<{ success: boolean; cancelled: boolean }> {
if (cutterExportActive || appShutdownStarted) return { success: false, cancelled: false };
cutterExportActive = true;
cutterExportCancelled = false;
try {
const success = await performVideoEditExport(request, onProgress);
return { success, cancelled: !success && cutterExportCancelled };
} catch (error) {
appendDebugLog('video-editor-export-failed', String(error));
return { success: false, cancelled: cutterExportCancelled };
} finally {
cutterExportActive = false;
cutterExportCancelled = false;
if (currentCutterPartialFile && !currentCutterProcess) {
try { fs.rmSync(currentCutterPartialFile, { force: true }); } catch { }
currentCutterPartialFile = null;
}
}
}
// ========================================== // ==========================================
// VIDEO CUTTER // VIDEO CUTTER
// ========================================== // ==========================================
@@ -2750,6 +3274,7 @@ async function extractFrame(filePath: string, timeSeconds: number): Promise<stri
]; ];
const proc = spawn(ffmpeg, args, { windowsHide: true }); const proc = spawn(ffmpeg, args, { windowsHide: true });
proc.stderr?.resume();
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0 && fs.existsSync(tempFile)) { if (code === 0 && fs.existsSync(tempFile)) {
@@ -6190,7 +6715,13 @@ function createWindow(): void {
mainWindow.removeMenu(); mainWindow.removeMenu();
} }
mainWindow.loadFile(path.join(__dirname, '../src/index.html')); const rendererFile = path.join(__dirname, '../src/index.html');
const rendererUrl = pathToFileURL(rendererFile).href;
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.split(/[?#]/, 1)[0] !== rendererUrl) event.preventDefault();
});
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
mainWindow.loadFile(rendererFile);
mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.on('did-finish-load', () => {
emitQueueUpdated(true); emitQueueUpdated(true);
@@ -6971,7 +7502,7 @@ ipcMain.handle('select-video-file', async () => {
const result = await dialog.showOpenDialog(mainWindow!, { const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile'], properties: ['openFile'],
filters: [ filters: [
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } { name: 'Video Files', extensions: ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'] }
] ]
}); });
return result.filePaths[0] || null; return result.filePaths[0] || null;
@@ -7433,15 +7964,85 @@ ipcMain.handle('import-config', async () => {
} }
}); });
function isTrustedRendererEvent(event: IpcMainInvokeEvent): boolean {
if (!mainWindow || event.sender.id !== mainWindow.webContents.id) return false;
const rendererUrl = pathToFileURL(path.join(__dirname, '../src/index.html')).href;
const senderUrl = event.senderFrame?.url || event.sender.getURL();
return senderUrl.split(/[?#]/, 1)[0] === rendererUrl;
}
function isPathInsideDirectory(rootDirectory: string, candidate: string): boolean {
const root = normalizeComparablePath(rootDirectory);
const target = normalizeComparablePath(candidate);
const relative = path.relative(root, target);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
}
// Video Cutter IPC // Video Cutter IPC
ipcMain.handle('get-video-info', async (_, filePath: string) => { ipcMain.handle('get-video-info', async (event, filePath: string) => {
return await getVideoInfo(filePath); if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
return await getVideoInfo(filePath, currentCutterInfoProcesses);
}); });
ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => { ipcMain.handle('extract-frame', async (event, filePath: string, timeSeconds: number) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
return await extractFrame(filePath, timeSeconds); return await extractFrame(filePath, timeSeconds);
}); });
ipcMain.handle('prepare-video-editor-media', async (event, filePath: string) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
const media = await prepareVideoEditorMedia(filePath);
if (media && cutterMediaJob?.jobId === media.jobId) cutterPreparedInput = cutterMediaJob.identity;
return media;
});
ipcMain.handle('prepare-video-editor-waveform', async (event, filePath: string, jobId: number) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
return await prepareVideoEditorWaveform(filePath, jobId);
});
ipcMain.handle('prepare-video-editor-assets', async (event, filePath: string, jobId: number, profile: VideoEditorAssetProfile) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
return await prepareVideoEditorAssets(filePath, jobId, profile);
});
ipcMain.handle('cancel-video-editor-assets', (event, jobId: number) => {
if (!isTrustedRendererEvent(event) || !Number.isInteger(jobId) || cutterMediaJob?.jobId !== jobId) return false;
cancelCutterMediaPreparation();
return true;
});
ipcMain.handle('export-video-edit', async (event, request: VideoEditExportRequest) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputFile !== 'string') return { success: false, outputFile: null };
if (!cutterInputIdentityMatches(request.inputFile)) return { success: false, outputFile: null };
let outputFile: string | null = null;
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
if (testRoot && typeof request.outputFile === 'string' && isPathInsideDirectory(testRoot, request.outputFile)) {
outputFile = request.outputFile;
} else {
const defaultName = path.join(path.dirname(request.inputFile), `${path.basename(request.inputFile, path.extname(request.inputFile))}_edited.mp4`);
const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: defaultName,
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
});
if (result.canceled || !result.filePath) return { success: false, outputFile: null, cancelled: true };
outputFile = result.filePath;
}
const outcome = await exportVideoEdit({ ...request, outputFile }, (percent) => {
mainWindow?.webContents.send('cut-progress', percent);
});
return { success: outcome.success, outputFile: outcome.success ? outputFile : null, cancelled: outcome.cancelled || undefined };
});
ipcMain.handle('cancel-video-edit', (event) => {
if (!isTrustedRendererEvent(event) || !cutterExportActive) return false;
cutterExportCancelled = true;
for (const process of currentCutterExportProcesses) {
try { process.kill(); } catch { }
}
return true;
});
ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => { ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => {
const dir = path.dirname(inputFile); const dir = path.dirname(inputFile);
const baseName = path.basename(inputFile, path.extname(inputFile)); const baseName = path.basename(inputFile, path.extname(inputFile));
@@ -7495,11 +8096,40 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
let appDb: DbHandle | null = null; let appDb: DbHandle | null = null;
export function getAppDb(): DbHandle | null { return appDb; } export function getAppDb(): DbHandle | null { return appDb; }
function cleanupStaleCutterMediaDirectories(): number {
const tempRoot = path.resolve(app.getPath('temp'));
let removed = 0;
try {
for (const name of fs.readdirSync(tempRoot)) {
if (!/^tvm-editor-(?:media|waveform|preview)-[A-Za-z0-9_-]+$/.test(name)) continue;
const candidate = path.resolve(tempRoot, name);
if (path.dirname(candidate) !== tempRoot) continue;
const processMatch = name.match(/^tvm-editor-(?:media|waveform|preview)-(\d+)-/);
if (processMatch) {
const ownerPid = Number(processMatch[1]);
if (ownerPid === process.pid) continue;
try {
process.kill(ownerPid, 0);
continue;
} catch { }
} else {
const ageMs = Date.now() - fs.statSync(candidate).mtimeMs;
if (ageMs < 24 * 60 * 60 * 1000) continue;
}
fs.rmSync(candidate, { recursive: true, force: true });
removed += 1;
}
} catch { }
return removed;
}
app.whenReady().then(() => { app.whenReady().then(() => {
const removedPartialDownloads = partialDownloadRegistry.cleanup(); const removedPartialDownloads = partialDownloadRegistry.cleanup();
if (removedPartialDownloads.length > 0) { if (removedPartialDownloads.length > 0) {
appendDebugLog('partial-downloads-cleaned-on-startup', { count: removedPartialDownloads.length }); appendDebugLog('partial-downloads-cleaned-on-startup', { count: removedPartialDownloads.length });
} }
const removedCutterMediaDirectories = cleanupStaleCutterMediaDirectories();
if (removedCutterMediaDirectories > 0) appendDebugLog('cutter-media-cleaned-on-startup', { count: removedCutterMediaDirectories });
refreshBundledToolPaths(true); refreshBundledToolPaths(true);
startMetadataCacheCleanup(); startMetadataCacheCleanup();
startDebugLogFlushTimer(); startDebugLogFlushTimer();
@@ -7544,9 +8174,26 @@ let shutdownCleanupDone = false;
let quitAfterCleanup = false; let quitAfterCleanup = false;
let shutdownPromise: Promise<void> | null = null; let shutdownPromise: Promise<void> | null = null;
function waitForChildProcessClose(process: ChildProcess | null, timeoutMs = 5000): Promise<void> {
if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(finish, timeoutMs);
process.once('close', finish);
process.once('error', finish);
});
}
async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise<void> { async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise<void> {
if (shutdownCleanupDone) return; if (shutdownCleanupDone) return;
shutdownCleanupDone = true; shutdownCleanupDone = true;
appShutdownStarted = true;
appendDebugLog('shutdown-cleanup', { reason }); appendDebugLog('shutdown-cleanup', { reason });
@@ -7579,10 +8226,34 @@ async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Pro
activeClipProcesses.clear(); activeClipProcesses.clear();
if (currentEditorProcess) { if (currentEditorProcess) {
try { currentEditorProcess.kill(); } catch { /* already exited */ } const editorProcess = currentEditorProcess;
try { editorProcess.kill(); } catch { /* already exited */ }
await waitForChildProcessClose(editorProcess);
currentEditorProcess = null; currentEditorProcess = null;
} }
if (cutterExportActive) cutterExportCancelled = true;
const exportProcesses = [...currentCutterExportProcesses];
for (const process of exportProcesses) {
try { process.kill(); } catch { }
}
await Promise.all(exportProcesses.map((process) => waitForChildProcessClose(process)));
if (currentCutterProcess && exportProcesses.includes(currentCutterProcess)) currentCutterProcess = null;
const mediaProcesses = [...currentCutterMediaProcesses, ...currentCutterWaveformProcesses, ...currentCutterProbeProcesses, ...currentCutterInfoProcesses, ...currentCutterPreviewProcesses];
cancelCutterMediaPreparation();
cancelCutterWaveformPreparation();
cancelCutterMetadataPreparation();
cancelCutterPreviewPreparation();
for (const process of currentCutterInfoProcesses) {
try { process.kill(); } catch { }
}
await Promise.all(mediaProcesses.map((process) => waitForChildProcessClose(process)));
removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null);
if (currentCutterPartialFile) {
try { fs.rmSync(currentCutterPartialFile, { force: true }); } catch { }
currentCutterPartialFile = null;
}
saveConfig(config); saveConfig(config);
flushQueueSave(); flushQueueSave();
+1 -1
View File
@@ -25,7 +25,7 @@ describe.runIf(process.platform === 'win32')('prepareWindowsDevExecutable', () =
sourcePath, sourcePath,
destinationPath, destinationPath,
iconPath, iconPath,
version: '1.0.4' version: '1.0.5'
}); });
const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath)); const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath));
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, test } from 'vitest';
import { calculateCutterExportProgress, createCutterExportPlan } from './cutter-export';
describe('cutter export segments', () => {
test('sorts playable segments and preserves the caller input', () => {
const segments = [
{ start: 30, end: 45 },
{ start: 5, end: 20 },
];
const plan = createCutterExportPlan({
inputFile: 'D:\\media\\source.mp4',
outputFile: 'D:\\media\\result.mp4',
segments,
hasAudio: true,
});
expect(plan.segments).toEqual([
{ start: 5, end: 20 },
{ start: 30, end: 45 },
]);
expect(plan.remainingDuration).toBe(30);
expect(segments).toEqual([
{ start: 30, end: 45 },
{ start: 5, end: 20 },
]);
});
test('builds an audio and video concat filter with a separated argument list', () => {
const plan = createCutterExportPlan({
inputFile: 'D:\\media folder\\source.mp4',
outputFile: 'D:\\exports\\result.mp4',
segments: [
{ start: 5, end: 20 },
{ start: 30.5, end: 45.25 },
],
hasAudio: true,
});
expect(plan.filterComplex).toBe('[0:v]trim=start=5:end=20,setpts=PTS-STARTPTS[v0];[0:a]atrim=start=5:end=20,asetpts=PTS-STARTPTS[a0];[0:v]trim=start=30.5:end=45.25,setpts=PTS-STARTPTS[v1];[0:a]atrim=start=30.5:end=45.25,asetpts=PTS-STARTPTS[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[outv][outa]');
expect(plan.ffmpegArgs).toEqual([
'-i', 'D:\\media folder\\source.mp4',
'-filter_complex', plan.filterComplex,
'-map', '[outv]',
'-map', '[outa]',
'-c:v', 'libx264',
'-preset', 'veryfast',
'-crf', '20',
'-pix_fmt', 'yuv420p',
'-c:a', 'aac',
'-b:a', '160k',
'-movflags', '+faststart',
'-progress', 'pipe:1',
'-y', 'D:\\exports\\result.mp4',
]);
});
test('builds a video-only concat filter without audio mappings or codecs', () => {
const plan = createCutterExportPlan({
inputFile: 'input.mkv',
outputFile: 'output.mp4',
segments: [
{ start: 0, end: 10 },
{ start: 12, end: 18 },
],
hasAudio: false,
});
expect(plan.filterComplex).toBe('[0:v]trim=start=0:end=10,setpts=PTS-STARTPTS[v0];[0:v]trim=start=12:end=18,setpts=PTS-STARTPTS[v1];[v0][v1]concat=n=2:v=1:a=0[outv]');
expect(plan.ffmpegArgs).toEqual([
'-i', 'input.mkv',
'-filter_complex', plan.filterComplex,
'-map', '[outv]',
'-c:v', 'libx264',
'-preset', 'veryfast',
'-crf', '20',
'-pix_fmt', 'yuv420p',
'-an',
'-movflags', '+faststart',
'-progress', 'pipe:1',
'-y', 'output.mp4',
]);
});
test.each([
{ name: 'empty segment list', segments: [] },
{ name: 'negative start', segments: [{ start: -1, end: 2 }] },
{ name: 'zero duration', segments: [{ start: 2, end: 2 }] },
{ name: 'reversed range', segments: [{ start: 3, end: 2 }] },
{ name: 'non-finite start', segments: [{ start: Number.NaN, end: 2 }] },
{ name: 'non-finite end', segments: [{ start: 1, end: Number.POSITIVE_INFINITY }] },
{ name: 'range below export precision', segments: [{ start: 1, end: 1.0000000001 }] },
{ name: 'overlap after sorting', segments: [{ start: 10, end: 20 }, { start: 5, end: 12 }] },
])('rejects $name', ({ segments }) => {
expect(() => createCutterExportPlan({
inputFile: 'input.mp4',
outputFile: 'output.mp4',
segments,
hasAudio: true,
})).toThrow();
});
test('calculates progress against the remaining segment duration and clamps it', () => {
const plan = createCutterExportPlan({
inputFile: 'input.mp4',
outputFile: 'output.mp4',
segments: [{ start: 10, end: 20 }, { start: 50, end: 70 }],
hasAudio: true,
});
expect(calculateCutterExportProgress(0, plan)).toBe(0);
expect(calculateCutterExportProgress(15, plan)).toBe(50);
expect(calculateCutterExportProgress(45, plan)).toBe(100);
expect(calculateCutterExportProgress(-5, plan)).toBe(0);
});
});
+109
View File
@@ -0,0 +1,109 @@
import type { EditorSegment } from './video-editor';
export interface CutterExportPlanOptions {
inputFile: string;
outputFile: string;
segments: readonly EditorSegment[];
hasAudio: boolean;
}
export interface CutterExportPlan {
segments: EditorSegment[];
remainingDuration: number;
filterComplex: string;
ffmpegArgs: string[];
}
const precision = 9;
function round(value: number): number {
return Number(value.toFixed(precision));
}
function formatSeconds(value: number): string {
return value.toFixed(precision).replace(/\.?0+$/, '');
}
function validatePath(value: string, name: string): string {
if (!value.trim()) throw new Error(`${name} must not be empty`);
return value;
}
function normalizeSegments(segments: readonly EditorSegment[]): EditorSegment[] {
if (segments.length === 0) throw new Error('At least one playable segment is required');
const normalized = segments.map((segment) => {
if (!Number.isFinite(segment.start) || !Number.isFinite(segment.end)) {
throw new Error('Segment boundaries must be finite');
}
if (segment.start < 0 || segment.end <= segment.start) {
throw new Error('Segment boundaries must be ordered and non-negative');
}
const start = round(segment.start);
const end = round(segment.end);
if (end <= start) throw new Error('Segment duration is below export precision');
return { start, end };
}).sort((left, right) => left.start - right.start || left.end - right.end);
for (let index = 1; index < normalized.length; index += 1) {
if (normalized[index].start < normalized[index - 1].end) {
throw new Error('Playable segments must not overlap');
}
}
return normalized;
}
function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boolean): string {
const filters: string[] = [];
const concatInputs: string[] = [];
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}]`);
concatInputs.push(`[v${index}]`);
if (hasAudio) {
filters.push(`[0:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${index}]`);
concatInputs.push(`[a${index}]`);
}
});
filters.push(`${concatInputs.join('')}concat=n=${segments.length}:v=1:a=${hasAudio ? 1 : 0}[outv]${hasAudio ? '[outa]' : ''}`);
return filters.join(';');
}
function createFfmpegArgs(inputFile: string, outputFile: string, filterComplex: string, hasAudio: boolean): string[] {
const args = [
'-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);
return args;
}
export function createCutterExportPlan(options: CutterExportPlanOptions): CutterExportPlan {
const inputFile = validatePath(options.inputFile, 'inputFile');
const outputFile = validatePath(options.outputFile, 'outputFile');
const segments = normalizeSegments(options.segments);
const remainingDuration = round(segments.reduce((total, segment) => total + segment.end - segment.start, 0));
const filterComplex = createFilterComplex(segments, options.hasAudio);
return {
segments,
remainingDuration,
filterComplex,
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio),
};
}
export function calculateCutterExportProgress(processedSeconds: number, plan: Pick<CutterExportPlan, 'remainingDuration'>): number {
if (!Number.isFinite(processedSeconds) || processedSeconds <= 0) return 0;
if (!Number.isFinite(plan.remainingDuration) || plan.remainingDuration <= 0) {
throw new Error('remainingDuration must be greater than zero');
}
return Math.min(100, round(processedSeconds / plan.remainingDuration * 100));
}
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, test } from 'vitest';
import {
addCutAt,
commitEditorState,
createEditorHistory,
createVideoEditorState,
formatEditorTimecode,
getPlayableSegments,
movePreviewTimeOutOfCuts,
parseEditorTimecode,
redoEditorState,
setCutRange,
setTrimRange,
timeToTimelinePercent,
timelinePercentToTime,
undoEditorState,
} from './video-editor';
describe('video editor timecodes', () => {
test('formats minute and hour timecodes with a frame field', () => {
expect(formatEditorTimecode(66.52, 25)).toBe('01:06:13');
expect(formatEditorTimecode(3666.52, 25)).toBe('01:01:06:13');
});
test('parses minute and hour timecodes and snaps to a real frame', () => {
expect(parseEditorTimecode('01:06:13', 25)).toBeCloseTo(66.52, 8);
expect(parseEditorTimecode('01:01:06:13', 25)).toBeCloseTo(3666.52, 8);
expect(parseEditorTimecode('00:02:29', 30)).toBeCloseTo(2.9666666667, 8);
});
});
describe('video editor ranges', () => {
test('keeps global trim boundaries frame-aligned and ordered', () => {
const state = setTrimRange(createVideoEditorState(120, 25), 10.019, 90.021);
expect(state.trimStart).toBe(10);
expect(state.trimEnd).toBe(90.04);
});
test('adds multiple cuts in timeline order without allowing overlaps', () => {
const first = addCutAt(createVideoEditorState(120, 25), 40, 8);
const second = addCutAt(first.state, 12, 5);
expect(second.state.cuts.map((cut) => [cut.start, cut.end])).toEqual([[12, 17], [40, 48]]);
const rejected = setCutRange(second.state, first.cut.id, 15, 44);
expect(rejected).toEqual(second.state);
});
test('removes cut ranges from the export while preserving every playable segment', () => {
let state = setTrimRange(createVideoEditorState(100, 25), 5, 95);
state = addCutAt(state, 20, 10).state;
state = addCutAt(state, 60, 5).state;
expect(getPlayableSegments(state)).toEqual([
{ start: 5, end: 20 },
{ start: 30, end: 60 },
{ start: 65, end: 95 },
]);
});
test('preview playback jumps to the end of any removed range', () => {
let state = addCutAt(createVideoEditorState(100, 25), 20, 10).state;
state = addCutAt(state, 60, 5).state;
expect(movePreviewTimeOutOfCuts(state, 24)).toBe(30);
expect(movePreviewTimeOutOfCuts(state, 64.99)).toBe(65);
expect(movePreviewTimeOutOfCuts(state, 40)).toBe(40);
});
test('clips cuts to a smaller trim and removes empty remainders', () => {
let state = addCutAt(createVideoEditorState(100, 25), 10, 15).state;
state = addCutAt(state, 70, 20).state;
state = setTrimRange(state, 20, 75);
expect(state.cuts.map((cut) => [cut.start, cut.end])).toEqual([[20, 25], [70, 75]]);
state = setTrimRange(state, 30, 60);
expect(state.cuts).toEqual([]);
});
test('rejects empty cuts while allowing adjacent cut boundaries', () => {
const initial = createVideoEditorState(100, 25);
expect(() => addCutAt(initial, 10, 0)).toThrow();
let state = addCutAt(initial, 10, 5).state;
state = addCutAt(state, 15, 5).state;
expect(movePreviewTimeOutOfCuts(state, 10)).toBe(20);
expect(getPlayableSegments(state)).toEqual([
{ start: 0, end: 10 },
{ start: 20, end: 100 },
]);
});
test('keeps at least one playable frame', () => {
const state = createVideoEditorState(10, 25);
expect(() => addCutAt(state, 0, 10)).toThrow('playable frame');
const cut = addCutAt(state, 1, 2);
expect(setCutRange(cut.state, cut.cut.id, 0, 10)).toEqual(cut.state);
expect(setTrimRange(cut.state, 1, 2)).toEqual(cut.state);
});
test('accepts exactly one playable frame at repeating frame rates', () => {
const state = createVideoEditorState(1, 30);
const edited = addCutAt(state, 0, 29 / 30).state;
expect(getPlayableSegments(edited)).toEqual([{ start: 0.966666667, end: 1 }]);
});
test('limits an edit to 64 removed ranges', () => {
let state = createVideoEditorState(200, 25);
for (let index = 0; index < 64; index += 1) state = addCutAt(state, index * 2, 1).state;
expect(state.cuts).toHaveLength(64);
expect(() => addCutAt(state, 150, 1)).toThrow('64');
});
test('converts timeline percentages and times at frame precision', () => {
const state = createVideoEditorState(120, 25);
expect(timeToTimelinePercent(state, 60)).toBe(50);
expect(timelinePercentToTime(state, 50.01)).toBe(60);
expect(timelinePercentToTime(state, 100)).toBe(120);
});
});
describe('video editor history', () => {
test('undo and redo restore complete trim and cut states', () => {
const initial = createVideoEditorState(100, 25);
const trimmed = setTrimRange(initial, 5, 90);
const cut = addCutAt(trimmed, 20, 10).state;
let history = createEditorHistory(initial);
history = commitEditorState(history, trimmed);
history = commitEditorState(history, cut);
history = undoEditorState(history);
expect(history.present).toEqual(trimmed);
history = undoEditorState(history);
expect(history.present).toEqual(initial);
history = redoEditorState(history);
expect(history.present).toEqual(trimmed);
history = redoEditorState(history);
expect(history.present).toEqual(cut);
});
test('does not record no-op changes and clears redo after a new edit', () => {
const initial = createVideoEditorState(100, 25);
const trimmed = setTrimRange(initial, 5, 90);
let history = commitEditorState(createEditorHistory(initial), trimmed);
history = commitEditorState(history, trimmed);
expect(history.past).toHaveLength(1);
history = undoEditorState(history);
expect(history.future).toHaveLength(1);
history = commitEditorState(history, setTrimRange(history.present, 10, 80));
expect(history.future).toEqual([]);
});
});
+268
View File
@@ -0,0 +1,268 @@
export interface EditorCut {
id: string;
start: number;
end: number;
}
export interface EditorSegment {
start: number;
end: number;
}
export interface VideoEditorState {
duration: number;
fps: number;
trimStart: number;
trimEnd: number;
cuts: EditorCut[];
}
export interface EditorHistory {
past: VideoEditorState[];
present: VideoEditorState;
future: VideoEditorState[];
}
const precision = 9;
const frameTolerance = 1e-8;
export const maxVideoEditorCuts = 64;
function finitePositive(value: number, name: string): number {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be greater than zero`);
}
return value;
}
function rounded(value: number): number {
return Number(value.toFixed(precision));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function snapToFrame(value: number, fps: number): number {
return rounded(Math.round(value * fps) / fps);
}
function cloneState(state: VideoEditorState): VideoEditorState {
return {
duration: state.duration,
fps: state.fps,
trimStart: state.trimStart,
trimEnd: state.trimEnd,
cuts: state.cuts.map((cut) => ({ ...cut })),
};
}
function statesEqual(left: VideoEditorState, right: VideoEditorState): boolean {
return left.duration === right.duration
&& left.fps === right.fps
&& left.trimStart === right.trimStart
&& left.trimEnd === right.trimEnd
&& left.cuts.length === right.cuts.length
&& left.cuts.every((cut, index) => {
const other = right.cuts[index];
return cut.id === other.id && cut.start === other.start && cut.end === other.end;
});
}
function nextCutId(cuts: EditorCut[]): string {
const used = new Set(cuts.map((cut) => cut.id));
let index = cuts.length + 1;
while (used.has(`cut-${index}`)) index += 1;
return `cut-${index}`;
}
function overlapsAnotherCut(cuts: EditorCut[], id: string, start: number, end: number): boolean {
return cuts.some((cut) => cut.id !== id && start < cut.end && end > cut.start);
}
function sortCuts(cuts: EditorCut[]): EditorCut[] {
return [...cuts].sort((left, right) => left.start - right.start || left.end - right.end || left.id.localeCompare(right.id));
}
function hasPlayableFrame(state: VideoEditorState, cuts: EditorCut[]): boolean {
const removedDuration = cuts.reduce((total, cut) => total + cut.end - cut.start, 0);
return state.trimEnd - state.trimStart - removedDuration >= 1 / state.fps - frameTolerance;
}
export function createVideoEditorState(duration: number, fps: number): VideoEditorState {
const safeDuration = finitePositive(duration, 'duration');
const safeFps = finitePositive(fps, 'fps');
return {
duration: rounded(safeDuration),
fps: safeFps,
trimStart: 0,
trimEnd: rounded(safeDuration),
cuts: [],
};
}
export function formatEditorTimecode(time: number, fps: number): string {
const safeFps = finitePositive(fps, 'fps');
let wholeSeconds = Math.floor(Math.max(0, time));
let frames = Math.round((Math.max(0, time) - wholeSeconds) * safeFps);
const frameBase = Math.max(1, Math.round(safeFps));
if (frames >= frameBase) {
wholeSeconds += 1;
frames = 0;
}
const hours = Math.floor(wholeSeconds / 3600);
const minutes = Math.floor((wholeSeconds % 3600) / 60);
const seconds = wholeSeconds % 60;
const fields = hours > 0
? [hours, minutes, seconds, frames]
: [minutes, seconds, frames];
return fields.map((field) => String(field).padStart(2, '0')).join(':');
}
export function parseEditorTimecode(value: string, fps: number): number {
const safeFps = finitePositive(fps, 'fps');
const fields = value.trim().split(':');
if (fields.length !== 3 && fields.length !== 4) {
throw new Error('Timecode must use MM:SS:FF or HH:MM:SS:FF');
}
const numbers = fields.map((field) => Number(field));
if (numbers.some((field) => !Number.isInteger(field) || field < 0)) {
throw new Error('Timecode fields must be non-negative integers');
}
const [hours, minutes, seconds, frames] = numbers.length === 4
? numbers
: [0, numbers[0], numbers[1], numbers[2]];
if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(safeFps))) {
throw new Error('Timecode field is out of range');
}
return rounded(hours * 3600 + minutes * 60 + seconds + frames / safeFps);
}
export function setTrimRange(state: VideoEditorState, start: number, end: number): VideoEditorState {
if (!Number.isFinite(start) || !Number.isFinite(end)) return state;
const frameDuration = 1 / state.fps;
const nextStart = clamp(snapToFrame(start, state.fps), 0, state.duration);
const nextEnd = clamp(snapToFrame(end, state.fps), 0, state.duration);
if (nextEnd - nextStart < frameDuration - frameTolerance) return state;
const cuts = state.cuts
.map((cut) => ({
...cut,
start: Math.max(cut.start, nextStart),
end: Math.min(cut.end, nextEnd),
}))
.filter((cut) => cut.end - cut.start >= frameDuration - frameTolerance);
const nextState = {
...state,
trimStart: rounded(nextStart),
trimEnd: rounded(nextEnd),
cuts: sortCuts(cuts),
};
return hasPlayableFrame(nextState, nextState.cuts) ? nextState : state;
}
export function addCutAt(state: VideoEditorState, start: number, duration: number): { state: VideoEditorState; cut: EditorCut } {
if (state.cuts.length >= maxVideoEditorCuts) throw new Error(`Edit supports up to ${maxVideoEditorCuts} removed ranges`);
const nextStart = clamp(snapToFrame(start, state.fps), state.trimStart, state.trimEnd);
const nextEnd = clamp(snapToFrame(start + duration, state.fps), state.trimStart, state.trimEnd);
const frameDuration = 1 / state.fps;
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0 || nextEnd - nextStart < frameDuration - frameTolerance) {
throw new Error('Cut must contain at least one frame');
}
const cut: EditorCut = { id: nextCutId(state.cuts), start: rounded(nextStart), end: rounded(nextEnd) };
if (overlapsAnotherCut(state.cuts, cut.id, cut.start, cut.end)) {
throw new Error('Cut overlaps another cut');
}
const cuts = sortCuts([...state.cuts, cut]);
if (!hasPlayableFrame(state, cuts)) throw new Error('Edit must keep at least one playable frame');
return {
cut,
state: { ...state, cuts },
};
}
export function setCutRange(state: VideoEditorState, id: string, start: number, end: number): VideoEditorState {
const existing = state.cuts.find((cut) => cut.id === id);
if (!existing || !Number.isFinite(start) || !Number.isFinite(end)) return state;
const nextStart = clamp(snapToFrame(start, state.fps), state.trimStart, state.trimEnd);
const nextEnd = clamp(snapToFrame(end, state.fps), state.trimStart, state.trimEnd);
if (nextEnd - nextStart < 1 / state.fps - frameTolerance) return state;
if (overlapsAnotherCut(state.cuts, id, nextStart, nextEnd)) return state;
const cuts = sortCuts(state.cuts.map((cut) => cut.id === id
? { ...cut, start: rounded(nextStart), end: rounded(nextEnd) }
: cut));
if (!hasPlayableFrame(state, cuts)) return state;
return {
...state,
cuts,
};
}
export function removeCut(state: VideoEditorState, id: string): VideoEditorState {
if (!state.cuts.some((cut) => cut.id === id)) return state;
return { ...state, cuts: state.cuts.filter((cut) => cut.id !== id) };
}
export function getPlayableSegments(state: VideoEditorState): EditorSegment[] {
const segments: EditorSegment[] = [];
let cursor = state.trimStart;
for (const cut of sortCuts(state.cuts)) {
const start = clamp(cut.start, state.trimStart, state.trimEnd);
const end = clamp(cut.end, state.trimStart, state.trimEnd);
if (start > cursor) segments.push({ start: rounded(cursor), end: rounded(start) });
cursor = Math.max(cursor, end);
}
if (cursor < state.trimEnd) segments.push({ start: rounded(cursor), end: rounded(state.trimEnd) });
return segments;
}
export function getPlayableDuration(state: VideoEditorState): number {
return rounded(getPlayableSegments(state).reduce((total, segment) => total + segment.end - segment.start, 0));
}
export function movePreviewTimeOutOfCuts(state: VideoEditorState, time: number): number {
let nextTime = clamp(time, state.trimStart, state.trimEnd);
for (const cut of sortCuts(state.cuts)) {
if (nextTime >= cut.start && nextTime < cut.end) nextTime = cut.end;
}
return rounded(clamp(nextTime, state.trimStart, state.trimEnd));
}
export function timeToTimelinePercent(state: VideoEditorState, time: number): number {
return clamp((time / state.duration) * 100, 0, 100);
}
export function timelinePercentToTime(state: VideoEditorState, percent: number): number {
return snapToFrame(clamp(percent, 0, 100) / 100 * state.duration, state.fps);
}
export function createEditorHistory(initial: VideoEditorState): EditorHistory {
return { past: [], present: cloneState(initial), future: [] };
}
export function commitEditorState(history: EditorHistory, next: VideoEditorState): EditorHistory {
if (statesEqual(history.present, next)) return history;
return {
past: [...history.past, cloneState(history.present)],
present: cloneState(next),
future: [],
};
}
export function undoEditorState(history: EditorHistory): EditorHistory {
const previous = history.past.at(-1);
if (!previous) return history;
return {
past: history.past.slice(0, -1),
present: cloneState(previous),
future: [cloneState(history.present), ...history.future],
};
}
export function redoEditorState(history: EditorHistory): EditorHistory {
const next = history.future[0];
if (!next) return history;
return {
past: [...history.past, cloneState(history.present)],
present: cloneState(next),
future: history.future.slice(1),
};
}
+49
View File
@@ -46,6 +46,49 @@ interface VideoInfo {
width: number; width: number;
height: number; height: number;
fps: number; fps: number;
hasAudio: boolean;
videoCodec: string;
audioCodec: string | null;
previewCompatible: boolean;
variableFrameRate: boolean;
}
interface VideoEditorMedia {
sourceUrl: string;
info: VideoInfo;
jobId: number;
thumbnails: string[];
waveform: string | null;
}
interface VideoEditorAssets {
jobId: number;
thumbnails: string[];
thumbnailSprite: string | null;
thumbnailCount: number;
pixelWidth: number;
pixelHeight: number;
}
interface VideoEditorWaveform {
jobId: number;
waveform: string | null;
pixelWidth: number;
pixelHeight: number;
}
interface VideoEditorAssetProfile {
timelineWidth: number;
trackHeight: number;
pixelRatio: number;
}
interface VideoEditExportRequest {
inputFile: string;
outputFile?: string;
trimStart: number;
trimEnd: number;
cuts: Array<{ id: string; start: number; end: number }>;
} }
// Expose protected methods to renderer // Expose protected methods to renderer
@@ -112,6 +155,12 @@ contextBridge.exposeInMainWorld('api', {
// Video Cutter // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds), extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
prepareVideoEditorMedia: (filePath: string): Promise<VideoEditorMedia | null> => ipcRenderer.invoke('prepare-video-editor-media', filePath),
prepareVideoEditorWaveform: (filePath: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', filePath, jobId),
prepareVideoEditorAssets: (filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> => ipcRenderer.invoke('prepare-video-editor-assets', filePath, jobId, profile),
cancelVideoEditorAssets: (jobId: number): Promise<boolean> => ipcRenderer.invoke('cancel-video-editor-assets', jobId),
exportVideoEdit: (request: VideoEditExportRequest): Promise<{ success: boolean; outputFile: string | null; cancelled?: boolean }> => ipcRenderer.invoke('export-video-edit', request),
cancelVideoEdit: (): Promise<boolean> => ipcRenderer.invoke('cancel-video-edit'),
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> => cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> =>
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime), ipcRenderer.invoke('cut-video', inputFile, startTime, endTime),
File diff suppressed because it is too large Load Diff
+49
View File
@@ -165,6 +165,49 @@ interface VideoInfo {
width: number; width: number;
height: number; height: number;
fps: number; fps: number;
hasAudio: boolean;
videoCodec: string;
audioCodec: string | null;
previewCompatible: boolean;
variableFrameRate: boolean;
}
interface VideoEditorMedia {
sourceUrl: string;
info: VideoInfo;
jobId: number;
thumbnails: string[];
waveform: string | null;
}
interface VideoEditorAssets {
jobId: number;
thumbnails: string[];
thumbnailSprite: string | null;
thumbnailCount: number;
pixelWidth: number;
pixelHeight: number;
}
interface VideoEditorWaveform {
jobId: number;
waveform: string | null;
pixelWidth: number;
pixelHeight: number;
}
interface VideoEditorAssetProfile {
timelineWidth: number;
trackHeight: number;
pixelRatio: number;
}
interface VideoEditExportRequest {
inputFile: string;
outputFile?: string;
trimStart: number;
trimEnd: number;
cuts: Array<{ id: string; start: number; end: number }>;
} }
interface ClipDialogData { interface ClipDialogData {
@@ -375,6 +418,12 @@ interface ApiBridge {
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void; onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
getVideoInfo(filePath: string): Promise<VideoInfo | null>; getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null>;
prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null>;
prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
cancelVideoEditorAssets(jobId: number): Promise<boolean>;
exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputFile: string | null; cancelled?: boolean }>;
cancelVideoEdit(): Promise<boolean>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>; mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>;
getVersion(): Promise<string>; getVersion(): Promise<string>;
+50 -9
View File
@@ -57,7 +57,7 @@ const UI_TEXT_DE = {
commandPaletteHint: 'Auf/Ab zum Navigieren, Enter zum Ausführen, Esc zum Schließen', commandPaletteHint: 'Auf/Ab zum Navigieren, Enter zum Ausführen, Esc zum Schließen',
mergeTitle: 'Videos zusammenfügen', mergeTitle: 'Videos zusammenfügen',
mergeDesc: 'Wähle mehrere Videos aus, um sie zu einem Video zusammenzufügen. Die Reihenfolge kann geändert werden.', mergeDesc: 'Wähle mehrere Videos aus, um sie zu einem Video zusammenzufügen. Die Reihenfolge kann geändert werden.',
mergeAdd: '+ Videos hinzufügen', mergeAdd: 'Videos hinzufügen',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Hell', themeLight: 'Hell',
@@ -499,17 +499,58 @@ const UI_TEXT_DE = {
previewLoading: 'Lade Vorschau...', previewLoading: 'Lade Vorschau...',
previewUnavailable: 'Vorschau nicht verfügbar', previewUnavailable: 'Vorschau nicht verfügbar',
previewAlt: 'Vorschau', previewAlt: 'Vorschau',
cutting: 'Schneidet...', cutting: 'Exportiert…',
cut: 'Schneiden', cut: 'Video exportieren',
cutSuccess: 'Video erfolgreich geschnitten!', cutSuccess: 'Video erfolgreich exportiert!',
cutFailed: 'Fehler beim Schneiden des Videos.', cutFailed: 'Fehler beim Exportieren des Videos.',
infoDuration: 'Dauer', infoDuration: 'Dauer',
infoResolution: 'Auflösung', infoResolution: 'Auflösung',
infoFps: 'FPS', infoFps: 'FPS',
infoSelection: 'Auswahl', infoSelection: 'Ausgabe',
startLabel: 'Start:', startLabel: 'Start',
endLabel: 'Ende:', endLabel: 'Ende',
filePathPlaceholder: 'Keine Datei ausgewählt…' filePathPlaceholder: 'Keine Datei ausgewählt…',
editorTitle: 'Trimmen & schneiden',
previewMode: 'Schnittvorschau',
previewModeHint: 'Entfernte Bereiche überspringen',
globalTrim: 'Gesamtauswahl',
cutsLabel: 'Entfernte Bereiche',
noCuts: 'Noch keine Schnitte',
newCut: 'Neuer Schnitt',
cutLabel: 'Schnitt',
removeCut: 'Schnitt entfernen',
videoTrack: 'VIDEO',
audioTrack: 'AUDIO',
noAudio: 'Keine Audiospur',
loadingMedia: 'Video wird vorbereitet…',
speedLabel: 'Geschwindigkeit',
play: 'Abspielen',
pause: 'Pausieren',
stop: 'Stopp',
rewind10: '10 Sekunden zurück',
forward10: '10 Sekunden vor',
mute: 'Stummschalten',
unmute: 'Ton einschalten',
volume: 'Lautstärke',
playerSettings: 'Player-Einstellungen',
fullscreen: 'Vollbild',
undo: 'Rückgängig',
redo: 'Wiederholen',
zoomOut: 'Timeline verkleinern',
zoomIn: 'Timeline vergrößern',
zoom: 'Timeline-Zoom',
trimStart: 'Startgrenze verschieben',
trimEnd: 'Endgrenze verschieben',
export: 'Video exportieren',
cancel: 'Abbrechen',
exportSuccess: 'Video wurde erfolgreich exportiert.',
exportFailed: 'Der Videoexport ist fehlgeschlagen.',
invalidRange: 'Der Zeitbereich ist ungültig oder überschneidet einen anderen Schnitt.',
unsupportedFile: 'Bitte eine unterstützte Videodatei auswählen.',
discardTitle: 'Aktuellen Schnitt verwerfen?',
discardMessage: 'Beim Öffnen eines anderen Videos gehen die aktuellen Schnittänderungen verloren.',
discardCancel: 'Aktuelles Video behalten',
discardConfirm: 'Verwerfen und öffnen'
}, },
merge: { merge: {
empty: 'Keine Videos ausgewahlt', empty: 'Keine Videos ausgewahlt',
+50 -9
View File
@@ -57,7 +57,7 @@ const UI_TEXT_EN = {
commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close', commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close',
mergeTitle: 'Merge videos', mergeTitle: 'Merge videos',
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.', mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
mergeAdd: '+ Add videos', mergeAdd: 'Add videos',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Light', themeLight: 'Light',
@@ -499,17 +499,58 @@ const UI_TEXT_EN = {
previewLoading: 'Loading preview...', previewLoading: 'Loading preview...',
previewUnavailable: 'Preview unavailable', previewUnavailable: 'Preview unavailable',
previewAlt: 'Preview', previewAlt: 'Preview',
cutting: 'Cutting...', cutting: 'Exporting',
cut: 'Cut', cut: 'Export video',
cutSuccess: 'Video cut successfully!', cutSuccess: 'Video exported successfully!',
cutFailed: 'Failed to cut video.', cutFailed: 'Failed to export video.',
infoDuration: 'Duration', infoDuration: 'Duration',
infoResolution: 'Resolution', infoResolution: 'Resolution',
infoFps: 'FPS', infoFps: 'FPS',
infoSelection: 'Selection', infoSelection: 'Output',
startLabel: 'Start:', startLabel: 'Start',
endLabel: 'End:', endLabel: 'End',
filePathPlaceholder: 'No file selected…' filePathPlaceholder: 'No file selected…',
editorTitle: 'Trim & cut',
previewMode: 'Cut preview',
previewModeHint: 'Skip removed ranges',
globalTrim: 'Global selection',
cutsLabel: 'Removed ranges',
noCuts: 'No cuts yet',
newCut: 'New cut',
cutLabel: 'Cut',
removeCut: 'Remove cut',
videoTrack: 'VIDEO',
audioTrack: 'AUDIO',
noAudio: 'No audio track',
loadingMedia: 'Preparing video…',
speedLabel: 'Playback speed',
play: 'Play',
pause: 'Pause',
stop: 'Stop',
rewind10: 'Back 10 seconds',
forward10: 'Forward 10 seconds',
mute: 'Mute',
unmute: 'Unmute',
volume: 'Volume',
playerSettings: 'Player settings',
fullscreen: 'Fullscreen',
undo: 'Undo',
redo: 'Redo',
zoomOut: 'Zoom timeline out',
zoomIn: 'Zoom timeline in',
zoom: 'Timeline zoom',
trimStart: 'Move start boundary',
trimEnd: 'Move end boundary',
export: 'Export video',
cancel: 'Cancel',
exportSuccess: 'Video exported successfully.',
exportFailed: 'Video export failed.',
invalidRange: 'The time range is invalid or overlaps another cut.',
unsupportedFile: 'Select a supported video file.',
discardTitle: 'Discard the current edit?',
discardMessage: 'Opening another video will discard the current editing changes.',
discardCancel: 'Keep current video',
discardConfirm: 'Discard and open'
}, },
merge: { merge: {
empty: 'No videos selected', empty: 'No videos selected',
-2
View File
@@ -70,8 +70,6 @@ let queueDragDropInitialized = false;
let cutterFile: string | null = null; let cutterFile: string | null = null;
let cutterVideoInfo: VideoInfo | null = null; let cutterVideoInfo: VideoInfo | null = null;
let cutterStartTime = 0;
let cutterEndTime = 0;
let isCutting = false; let isCutting = false;
let mergeFiles: string[] = []; let mergeFiles: string[] = [];
+7 -4
View File
@@ -485,13 +485,16 @@ function initCutterDragDrop(): void {
const files = Array.from(e.dataTransfer.files || []); const files = Array.from(e.dataTransfer.files || []);
if (files.length === 0) return; if (files.length === 0) return;
// First video-ish file wins const allowed = /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i;
const allowed = /\.(mp4|mkv|ts|mov|avi)$/i; const file = files.find((entry) => allowed.test(entry.name));
const file = files.find((f) => allowed.test(f.name)) || files[0]; if (!file) {
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
return;
}
const filePath = window.api.getPathForFile(file); const filePath = window.api.getPathForFile(file);
if (!filePath) return; if (!filePath) return;
const loader = (window as unknown as { loadCutterFromPath?: (p: string) => Promise<void> }).loadCutterFromPath; const loader = (window as unknown as { requestCutterVideoReplacement?: (p: string) => Promise<void> }).requestCutterVideoReplacement;
if (typeof loader === 'function') { if (typeof loader === 'function') {
await loader(filePath); await loader(filePath);
} }
+36
View File
@@ -176,9 +176,42 @@ function applyLanguageToStaticUI(): void {
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution); setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps); setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection); setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection);
setText('cutterDiscardTitle', UI_TEXT.cutter.discardTitle);
setText('cutterDiscardMessage', UI_TEXT.cutter.discardMessage);
setText('cutterDiscardCancelBtn', UI_TEXT.cutter.discardCancel);
setText('cutterDiscardConfirmBtn', UI_TEXT.cutter.discardConfirm);
setText('cutterStartLabel', UI_TEXT.cutter.startLabel); setText('cutterStartLabel', UI_TEXT.cutter.startLabel);
setText('cutterEndLabel', UI_TEXT.cutter.endLabel); setText('cutterEndLabel', UI_TEXT.cutter.endLabel);
setText('btnCut', UI_TEXT.cutter.cut); setText('btnCut', UI_TEXT.cutter.cut);
setText('cutterEditHeading', UI_TEXT.cutter.editorTitle);
setText('cutterPreviewModeLabel', UI_TEXT.cutter.previewMode);
setText('cutterPreviewModeHint', UI_TEXT.cutter.previewModeHint);
setText('cutterGlobalTrimLabel', UI_TEXT.cutter.globalTrim);
setText('cutterCutsLabel', UI_TEXT.cutter.cutsLabel);
setText('cutterCutEmpty', UI_TEXT.cutter.noCuts);
setText('cutterVideoTrackLabel', UI_TEXT.cutter.videoTrack);
setText('cutterAudioTrackLabel', UI_TEXT.cutter.audioTrack);
setText('cutterAudioEmpty', UI_TEXT.cutter.noAudio);
setText('cutterLoadingLabel', UI_TEXT.cutter.loadingMedia);
setText('cutterSpeedLabel', UI_TEXT.cutter.speedLabel);
setAriaLabel('cutterPlayBtn', UI_TEXT.cutter.play);
setAriaLabel('cutterStopBtn', UI_TEXT.cutter.stop);
setAriaLabel('cutterRewindBtn', UI_TEXT.cutter.rewind10);
setAriaLabel('cutterForwardBtn', UI_TEXT.cutter.forward10);
setAriaLabel('cutterMuteBtn', UI_TEXT.cutter.mute);
setAriaLabel('cutterVolume', UI_TEXT.cutter.volume);
setAriaLabel('cutterSettingsBtn', UI_TEXT.cutter.playerSettings);
setAriaLabel('cutterFullscreenBtn', UI_TEXT.cutter.fullscreen);
setAriaLabel('cutterUndoBtn', UI_TEXT.cutter.undo);
setAriaLabel('cutterRedoBtn', UI_TEXT.cutter.redo);
setAriaLabel('cutterZoomOutBtn', UI_TEXT.cutter.zoomOut);
setAriaLabel('cutterZoomInBtn', UI_TEXT.cutter.zoomIn);
setAriaLabel('cutterZoom', UI_TEXT.cutter.zoom);
setAriaLabel('cutterTrimStartHandle', UI_TEXT.cutter.trimStart);
setAriaLabel('cutterTrimEndHandle', UI_TEXT.cutter.trimEnd);
setText('cutterCancelExportBtn', UI_TEXT.cutter.cancel);
setAriaLabel('cutterNewCutBtn', UI_TEXT.cutter.newCut);
setTitle('cutterNewCutBtn', UI_TEXT.cutter.newCut);
setText('mergeTitle', UI_TEXT.static.mergeTitle); setText('mergeTitle', UI_TEXT.static.mergeTitle);
setText('mergeDesc', UI_TEXT.static.mergeDesc); setText('mergeDesc', UI_TEXT.static.mergeDesc);
setText('mergeAddBtn', UI_TEXT.static.mergeAdd); setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
@@ -413,6 +446,9 @@ function applyLanguageToStaticUI(): void {
if (typeof workspaceSync === 'function') { if (typeof workspaceSync === 'function') {
workspaceSync(activeTabId.replace(/Tab$/, '')); workspaceSync(activeTabId.replace(/Tab$/, ''));
} }
if (typeof updateCutterPlayUi === 'function') updateCutterPlayUi();
if (typeof updateCutterMuteUi === 'function') updateCutterMuteUi();
if (typeof renderCutterEditor === 'function') renderCutterEditor();
} }
function localizeCurrentStatusText(current: string): string { function localizeCurrentStatusText(current: string): string {
+9 -140
View File
@@ -75,6 +75,7 @@ async function init(): Promise<void> {
loadVodScrollPositions(); loadVodScrollPositions();
initVodScrollTracking(); initVodScrollTracking();
initCutterDragDrop(); initCutterDragDrop();
initCutterEditor();
// Restore last active tab from previous session (default 'vods') // Restore last active tab from previous session (default 'vods')
initTopNavActiveIndicator(); initTopNavActiveIndicator();
@@ -531,6 +532,12 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
function closeTopmostOpenModal(): boolean { function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order // Try each known modal in priority order
const cutterDiscardModal = document.getElementById('cutterDiscardModal');
if (cutterDiscardModal?.classList.contains('show')) {
resolveCutterDiscard(false);
return true;
}
const commandPaletteModal = document.getElementById('commandPaletteModal'); const commandPaletteModal = document.getElementById('commandPaletteModal');
if (commandPaletteModal?.classList.contains('show')) { if (commandPaletteModal?.classList.contains('show')) {
const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette; const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette;
@@ -974,6 +981,7 @@ function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
} }
function showTab(tab: string): void { function showTab(tab: string): void {
if (tab !== 'cutter' && byId('cutterTab').classList.contains('active') && typeof deactivateCutterEditor === 'function') deactivateCutterEditor();
queryAll('.nav-item').forEach((i) => { queryAll('.nav-item').forEach((i) => {
i.classList.remove('active'); i.classList.remove('active');
i.removeAttribute('aria-current'); i.removeAttribute('aria-current');
@@ -990,6 +998,7 @@ function showTab(tab: string): void {
navItem.setAttribute('aria-current', 'page'); navItem.setAttribute('aria-current', 'page');
syncTopNavActiveIndicator(); syncTopNavActiveIndicator();
byId(tab + 'Tab').classList.add('active'); byId(tab + 'Tab').classList.add('active');
if (tab === 'cutter' && typeof activateCutterEditor === 'function') activateCutterEditor();
syncWorkspaceChrome(tab); syncWorkspaceChrome(tab);
scheduleSegmentedIndicatorsSync(); scheduleSegmentedIndicatorsSync();
@@ -1671,146 +1680,6 @@ function initSegmentedIndicators(): void {
window.addEventListener('resize', scheduleSegmentedIndicatorsSync); window.addEventListener('resize', scheduleSegmentedIndicatorsSync);
} }
async function loadCutterFromPath(filePath: string): Promise<void> {
if (!filePath) return;
cutterFile = filePath;
byId<HTMLInputElement>('cutterFilePath').value = filePath;
const info = await window.api.getVideoInfo(filePath);
if (!info) {
alert(UI_TEXT.cutter.videoInfoFailed);
return;
}
cutterVideoInfo = info;
cutterStartTime = 0;
cutterEndTime = info.duration;
byId('cutterInfo').classList.add('shown');
byId('timelineContainer').classList.add('shown');
byId('btnCut').disabled = false;
byId('infoDuration').textContent = formatTime(info.duration);
byId('infoResolution').textContent = `${info.width}x${info.height}`;
byId('infoFps').textContent = Math.round(info.fps);
byId('infoSelection').textContent = formatTime(info.duration);
byId<HTMLInputElement>('startTime').value = '00:00:00';
byId<HTMLInputElement>('endTime').value = formatTime(info.duration);
updateTimeline();
await updatePreview(0);
}
async function selectCutterVideo(): Promise<void> {
const filePath = await window.api.selectVideoFile();
if (!filePath) return;
await loadCutterFromPath(filePath);
}
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
function parseTime(timeStr: string): number {
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return 0;
}
function updateTimeline(): void {
if (!cutterVideoInfo) {
return;
}
const selection = byId('timelineSelection');
const startPercent = (cutterStartTime / cutterVideoInfo.duration) * 100;
const endPercent = (cutterEndTime / cutterVideoInfo.duration) * 100;
selection.style.left = startPercent + '%';
selection.style.width = (endPercent - startPercent) + '%';
const duration = cutterEndTime - cutterStartTime;
byId('infoSelection').textContent = formatTime(duration);
}
function updateTimeFromInput(): void {
const startStr = byId<HTMLInputElement>('startTime').value;
const endStr = byId<HTMLInputElement>('endTime').value;
cutterStartTime = Math.max(0, parseTime(startStr));
cutterEndTime = Math.min(cutterVideoInfo?.duration || 0, parseTime(endStr));
if (cutterEndTime <= cutterStartTime) {
cutterEndTime = cutterStartTime + 1;
}
updateTimeline();
}
async function seekTimeline(event: MouseEvent): Promise<void> {
if (!cutterVideoInfo) {
return;
}
const timeline = byId<HTMLElement>('timeline');
const rect = timeline.getBoundingClientRect();
const percent = (event.clientX - rect.left) / rect.width;
const time = percent * cutterVideoInfo.duration;
byId('timelineCurrent').style.left = (percent * 100) + '%';
await updatePreview(time);
}
async function updatePreview(time: number): Promise<void> {
if (!cutterFile) {
return;
}
const preview = byId('cutterPreview');
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
const frame = await window.api.extractFrame(cutterFile, time);
if (frame) {
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
return;
}
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
}
async function startCutting(): Promise<void> {
if (!cutterFile || isCutting) {
return;
}
isCutting = true;
byId('btnCut').disabled = true;
byId('btnCut').textContent = UI_TEXT.cutter.cutting;
byId('cutProgress').classList.add('show');
const result = await window.api.cutVideo(cutterFile, cutterStartTime, cutterEndTime);
isCutting = false;
byId('btnCut').disabled = false;
byId('btnCut').textContent = UI_TEXT.cutter.cut;
byId('cutProgress').classList.remove('show');
if (result.success) {
alert(`${UI_TEXT.cutter.cutSuccess}\n\n${result.outputFile}`);
return;
}
alert(UI_TEXT.cutter.cutFailed);
}
async function addMergeFiles(): Promise<void> { async function addMergeFiles(): Promise<void> {
const files = await window.api.selectMultipleVideos(); const files = await window.api.selectMultipleVideos();
if (!files || files.length === 0) { if (!files || files.length === 0) {
+1050 -96
View File
File diff suppressed because it is too large Load Diff
+68 -3
View File
@@ -190,6 +190,16 @@ a:focus-visible {
box-shadow: none; box-shadow: none;
} }
input.cutter-volume:focus-visible {
outline: none;
box-shadow: none;
}
.cutter-volume-control:has(.cutter-volume:focus-visible) .cutter-player-button {
background: rgba(255, 255, 255, 0.14);
box-shadow: inset 0 0 0 2px var(--workspace-primary);
}
button:disabled, button:disabled,
input:disabled, input:disabled,
select:disabled, select:disabled,
@@ -1817,11 +1827,14 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
} }
.clip-input, .clip-input,
.cutter-container,
.merge-container { .merge-container {
max-width: 760px; max-width: 760px;
} }
.cutter-container {
max-width: none;
}
.clip-input { .clip-input {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -2168,6 +2181,44 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
margin-top: 18px; margin-top: 18px;
} }
.cutter-discard-modal {
max-width: 460px;
}
.cutter-discard-modal h2 {
margin: 0 0 10px;
}
.cutter-discard-modal p {
margin: 0;
color: var(--workspace-text-muted);
line-height: 1.5;
}
#cutterDiscardCancelBtn {
color: #000;
background: var(--workspace-primary);
border-color: var(--workspace-primary);
}
#cutterDiscardCancelBtn:hover:not(:disabled) {
color: #000;
background: var(--workspace-primary-hover);
border-color: var(--workspace-primary-hover);
}
#cutterDiscardConfirmBtn {
color: #000;
background: var(--workspace-danger);
border-color: var(--workspace-danger);
}
#cutterDiscardConfirmBtn:hover:not(:disabled) {
color: #000;
background: #f38d8d;
border-color: #f38d8d;
}
.update-modal { .update-modal {
background: var(--workspace-panel-raised); background: var(--workspace-panel-raised);
border-color: var(--workspace-border-strong); border-color: var(--workspace-border-strong);
@@ -2586,6 +2637,15 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
transition-delay: 0s !important; transition-delay: 0s !important;
} }
.cutter-volume {
transition: width 220ms cubic-bezier(0.2, 0.75, 0.25, 1), margin 220ms cubic-bezier(0.2, 0.75, 0.25, 1), opacity 150ms ease, visibility 0s linear 220ms !important;
}
.cutter-volume-control:hover .cutter-volume,
.cutter-volume-control:focus-within .cutter-volume {
transition-delay: 0s !important;
}
.top-nav::before { .top-nav::before {
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important; transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
} }
@@ -2951,8 +3011,9 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
} }
#settingsTab .language-picker .lang-option > span:not(.flag-icon) { #settingsTab .language-picker .lang-option > span:not(.flag-icon) {
color: #fff; color: var(--workspace-text-muted);
mix-blend-mode: difference; mix-blend-mode: normal;
transition: color 120ms ease;
} }
#settingsTab .language-picker .lang-option:hover { #settingsTab .language-picker .lang-option:hover {
@@ -2966,6 +3027,10 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
box-shadow: none; box-shadow: none;
} }
#settingsTab .language-picker .lang-option.active > span:not(.flag-icon) {
color: var(--workspace-primary-text);
}
#settingsTab .language-picker .lang-option.active:hover { #settingsTab .language-picker .lang-option.active:hover {
background: transparent; background: transparent;
} }