release: publish Twitch VOD Manager 1.0.5
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
+163
-45
@@ -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…">
|
||||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="video-preview" id="cutterPreview">
|
<div class="cutter-workspace" id="cutterWorkspace">
|
||||||
<div class="placeholder">
|
<aside class="cutter-sidebar">
|
||||||
<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>
|
<div class="cutter-sidebar-heading">
|
||||||
<p id="cutterPreviewPlaceholder">Video auswählen um Vorschau zu sehen</p>
|
<div>
|
||||||
</div>
|
<span class="cutter-eyebrow">EDITOR</span>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div class="cutter-info" id="cutterInfo">
|
<section class="cutter-preview-panel">
|
||||||
<div class="cutter-info-item">
|
<div class="video-preview" id="cutterPreview">
|
||||||
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
|
<video id="cutterVideo" preload="metadata"></video>
|
||||||
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
<div class="placeholder" id="cutterPreviewEmpty">
|
||||||
</div>
|
<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>
|
||||||
<div class="cutter-info-item">
|
<p id="cutterPreviewPlaceholder">Video auswählen, um eine Vorschau zu sehen</p>
|
||||||
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span>
|
</div>
|
||||||
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
<div class="cutter-player-loading" id="cutterPlayerLoading" hidden>
|
||||||
</div>
|
<span class="cutter-spinner"></span>
|
||||||
<div class="cutter-info-item">
|
<span id="cutterLoadingLabel">Video wird vorbereitet…</span>
|
||||||
<span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
|
</div>
|
||||||
<span class="cutter-info-value" id="infoFps">--</span>
|
<div class="cutter-player-controls" id="cutterPlayerControls">
|
||||||
</div>
|
<button type="button" class="cutter-player-button" id="cutterPlayBtn" onclick="toggleCutterPlayback()" disabled aria-label="Abspielen">
|
||||||
<div class="cutter-info-item">
|
<svg class="cutter-play-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
|
||||||
<span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
|
<svg class="cutter-pause-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M7 5h4v14H7zM13 5h4v14h-4z"></path></svg>
|
||||||
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
</button>
|
||||||
</div>
|
<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 class="cutter-info" id="cutterInfo">
|
||||||
|
<div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span><span class="cutter-info-value" id="infoDuration">--:--:--</span></div>
|
||||||
|
<div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span><span class="cutter-info-value" id="infoResolution">----×----</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">Ausgabe</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">
|
||||||
</div>
|
<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 class="time-inputs">
|
|
||||||
<div class="time-input-group">
|
|
||||||
<label id="cutterStartLabel" for="startTime">Start:</label>
|
|
||||||
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="time-input-group">
|
<div class="cutter-zoom-controls">
|
||||||
<label id="cutterEndLabel" for="endTime">Ende:</label>
|
<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>
|
||||||
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
<input type="range" id="cutterZoom" min="1" max="16" step="0.05" value="1" disabled aria-label="Timeline-Zoom">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="cutter-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="cutterCancelExportBtn" onclick="cancelCutterExport()" hidden>Abbrechen</button>
|
||||||
|
<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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
Vendored
+49
@@ -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>;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+68
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user