From c66e9c9ab179289ec1b20705fbd90d53ebffc88b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:24:48 +0200 Subject: [PATCH] Fix remaining UI state and shutdown edge cases Keep the loaded cutter layout stable across supported window sizes, preserve recovered encoder choices during capability discovery, and reject unsupported video selections without changing the active project. Honor the Windows system theme, fully localize runtime metrics, invalidate imported System Check state safely, and bound child-process shutdown waits when close events never arrive. Extend focused and Electron smoke coverage and prepare the v1.0.17 public release metadata. --- CHANGELOG.md | 9 + README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/dev.mjs | 2 +- scripts/smoke-test-cutter.js | 443 +++++++++++++++--- scripts/smoke-test-public-release-config.js | 8 +- scripts/smoke-test-workspace-ui.js | 205 +++++++- ...r-workspace-styles.production-path.test.ts | 17 +- src/index.html | 2 +- src/main.ts | 9 +- src/main/queue/process-registry.test.ts | 80 +++- src/main/queue/process-registry.ts | 20 +- src/renderer-cutter.production-path.test.ts | 185 ++++++++ src/renderer-cutter.ts | 30 +- src/renderer-locale-de.ts | 55 ++- src/renderer-locale-en.ts | 27 ++ src/renderer-settings-autosave.test.ts | 35 ++ src/renderer-settings.ts | 85 +++- src/renderer-streamers.ts | 7 +- src/styles.css | 10 +- 21 files changed, 1117 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a22882..765ed12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.17 - 2026-08-13 + +- Keep the loaded video cutter focused at every supported window size and give recovery notices their own layout space. +- Preserve recovered hardware encoder selections until export capabilities finish loading, and reject unsupported cutter files without changing the current project. +- Follow the Windows color scheme when the System theme is selected. +- Fully localize Runtime Metrics in German, including values, counts and error classes. +- Clear stale System Check results after configuration imports while keeping running controls correctly localized. +- Bound child-process shutdown waits so stalled exports cannot keep the application open indefinitely. + ## 1.0.16 - 2026-08-13 - Preserve completed System Check results when switching between German and English. diff --git a/README.md b/README.md index b39fb17..5b6182d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch ## Installation 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). -2. Download `Twitch-VOD-Manager-Setup-1.0.16.exe`. +2. Download `Twitch-VOD-Manager-Setup-1.0.17.exe`. 3. Run the installer and choose the installation directory. 4. Start Twitch VOD Manager and add a streamer. diff --git a/package-lock.json b/package-lock.json index 186dc51..b97ad87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.16", + "version": "1.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.16", + "version": "1.0.17", "license": "MIT", "dependencies": { "axios": "^1.16.1", diff --git a/package.json b/package.json index 1140d6c..70fab98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.16", + "version": "1.0.17", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index dd69700..207b2ec 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -93,7 +93,7 @@ if (process.platform === 'win32') { sourcePath: electronSourceExecutable, destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'), - version: '1.0.16', + version: '1.0.17', }); } diff --git a/scripts/smoke-test-cutter.js b/scripts/smoke-test-cutter.js index 7432a60..be35973 100644 --- a/scripts/smoke-test-cutter.js +++ b/scripts/smoke-test-cutter.js @@ -75,6 +75,29 @@ async function loadCutterCapability(win, filePath) { return capability; } +async function dropCutterFile(win, filePath) { + const inputId = `cutter-drop-${Date.now()}-${Math.random().toString(36).slice(2)}`; + await win.evaluate((id) => { + const input = document.createElement('input'); + input.type = 'file'; + input.id = id; + document.body.appendChild(input); + }, inputId); + await win.locator(`#${inputId}`).setInputFiles(filePath); + await win.evaluate((id) => { + const input = document.getElementById(id); + const file = input instanceof HTMLInputElement ? input.files?.[0] : null; + const tab = document.getElementById('cutterTab'); + if (!file || !tab) throw new Error('Cutter drop fixture is unavailable'); + const transfer = new DataTransfer(); + transfer.items.add(file); + for (const type of ['dragenter', 'dragover', 'drop']) { + tab.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer })); + } + input.remove(); + }, inputId); +} + function createTestVideo(environment) { const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4'); runBinary(resolveBinary(environment, 'ffmpeg'), [ @@ -143,6 +166,12 @@ function createUnsupportedVideo(environment, sourceFile) { return filePath; } +function createUnsupportedImage(environment) { + const filePath = path.join(environment.mediaDir, 'Unsupported drop.png'); + fs.writeFileSync(filePath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')); + return filePath; +} + function createLongVideo(environment) { const filePath = path.join(environment.mediaDir, 'Long Cutter Test.mp4'); runBinary(resolveBinary(environment, 'ffmpeg'), [ @@ -157,17 +186,19 @@ function createLongVideo(environment) { async function run() { const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' }); + const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1'; const inputFile = createTestVideo(environment); - const scrubStressInputFile = process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) + const scrubStressInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) ? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA : createScrubStressVideo(environment); - const mediumInputFile = process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) + const mediumInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) ? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA : createMediumVideo(environment); - const additionalContainerFiles = createAdditionalContainerVideos(environment, inputFile); - const unsupportedInputFile = createUnsupportedVideo(environment, inputFile); - const silentInputFile = createSilentPortraitVideo(environment); - const longInputFile = createLongVideo(environment); + const additionalContainerFiles = remaindersOnly ? {} : createAdditionalContainerVideos(environment, inputFile); + const unsupportedInputFile = remaindersOnly ? null : createUnsupportedVideo(environment, inputFile); + const unsupportedImageFile = createUnsupportedImage(environment); + const silentInputFile = remaindersOnly ? null : createSilentPortraitVideo(environment); + const longInputFile = remaindersOnly ? null : createLongVideo(environment); const outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4'); const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4'); const failures = []; @@ -200,33 +231,93 @@ async function run() { await win.setViewportSize({ width: 1440, height: 900 }); await win.emulateMedia({ reducedMotion: 'reduce' }); await win.evaluate(() => window.showTab('cutter')); + const cutterSourceVisibility = []; + for (const viewport of [{ width: 1060, height: 700 }, { width: 1180, height: 900 }, { width: 1184, height: 661 }, { width: 1440, height: 679 }, { width: 1440, height: 900 }, { width: 2048, height: 1152 }]) { + await win.setViewportSize(viewport); + cutterSourceVisibility.push(await win.evaluate((size) => { + const source = document.querySelector('.cutter-source-bar'); + const workspace = document.getElementById('cutterWorkspace'); + workspace.classList.remove('shown'); + const emptyDisplay = getComputedStyle(source).display; + const emptyHeight = source.getBoundingClientRect().height; + workspace.classList.add('shown'); + const loadedDisplay = getComputedStyle(source).display; + const loadedHeight = source.getBoundingClientRect().height; + return { + size, + emptyDisplay, + emptyHeight, + loadedDisplay, + loadedHeight + }; + }, viewport)); + } + check( + cutterSourceVisibility.every((entry) => entry.emptyDisplay !== 'none' && entry.emptyHeight > 0), + `The empty cutter source selector is not visible at every viewport: ${JSON.stringify(cutterSourceVisibility)}` + ); + check( + cutterSourceVisibility.every((entry) => entry.loadedDisplay === 'none' && entry.loadedHeight === 0), + `The loaded cutter source selector remains visible at some viewports: ${JSON.stringify(cutterSourceVisibility)}` + ); + await win.setViewportSize({ width: 1440, height: 900 }); await win.evaluate(() => { document.getElementById('cutterWorkspace').classList.add('shown'); - document.getElementById('cutterExportProfile').disabled = false; - }); - await win.locator('#cutterExportProfile').waitFor({ state: 'visible' }); - const cutterExportProfileBox = await win.locator('#cutterExportProfile').boundingBox(); - if (!cutterExportProfileBox) throw new Error('Export profile is not visible'); - await win.mouse.move(cutterExportProfileBox.x + cutterExportProfileBox.width / 2, cutterExportProfileBox.y + cutterExportProfileBox.height / 2); - const cutterExportProfilePresentation = await win.evaluate(() => { - const style = getComputedStyle(document.getElementById('cutterExportProfile')); - return { - backgroundImage: style.backgroundImage, - backgroundRepeat: style.backgroundRepeat, - backgroundPosition: style.backgroundPosition, - sourceDisplay: getComputedStyle(document.querySelector('.cutter-source-bar')).display, - }; + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) document.getElementById(id).disabled = false; }); + const cutterExportSelectPresentation = []; + for (const theme of ['theme-twitch', 'theme-light']) { + await win.evaluate((className) => { document.body.className = className; }, theme); + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) { + const select = win.locator(`#${id}`); + await select.waitFor({ state: 'visible' }); + for (const state of ['default', 'hover', 'focus', 'disabled']) { + await win.evaluate(({ selectId, selectState }) => { + const element = document.getElementById(selectId); + element.disabled = selectState === 'disabled'; + if (selectState !== 'focus') element.blur(); + }, { selectId: id, selectState: state }); + if (state === 'hover') await select.hover(); + if (state === 'focus') await select.focus(); + cutterExportSelectPresentation.push(await select.evaluate((element, meta) => { + const style = getComputedStyle(element); + const context = document.createElement('canvas').getContext('2d'); + context.font = style.font; + const textWidth = context.measureText(element.selectedOptions[0]?.textContent || '').width; + const availableTextWidth = element.getBoundingClientRect().width + - Number.parseFloat(style.paddingLeft) + - Number.parseFloat(style.paddingRight) + - Number.parseFloat(style.borderLeftWidth) + - Number.parseFloat(style.borderRightWidth); + return { + ...meta, + appearance: style.appearance, + backgroundImages: (style.backgroundImage.match(/url\(/g) || []).length, + backgroundRepeat: style.backgroundRepeat, + backgroundPosition: style.backgroundPosition, + paddingRight: Number.parseFloat(style.paddingRight), + textWidth, + availableTextWidth, + horizontalOverflow: element.scrollWidth - element.clientWidth + }; + }, { theme, id, state })); + } + } + } check( - cutterExportProfilePresentation.backgroundImage !== 'none' - && cutterExportProfilePresentation.backgroundRepeat === 'no-repeat' - && cutterExportProfilePresentation.backgroundPosition.includes('8px') - && cutterExportProfilePresentation.sourceDisplay === 'none', - `Export profile indicator is tiled or misplaced: ${JSON.stringify(cutterExportProfilePresentation)}` + cutterExportSelectPresentation.every((entry) => entry.appearance === 'none' + && entry.backgroundImages === 1 + && entry.backgroundRepeat === 'no-repeat' + && entry.backgroundPosition.includes('8px') + && entry.paddingRight >= 28 + && entry.textWidth <= entry.availableTextWidth + 0.5 + && entry.horizontalOverflow <= 1), + `Cutter export selects have duplicate indicators or clipped text: ${JSON.stringify(cutterExportSelectPresentation)}` ); await win.evaluate(() => { + document.body.className = 'theme-twitch'; document.getElementById('cutterWorkspace').classList.remove('shown'); - document.getElementById('cutterExportProfile').disabled = true; + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) document.getElementById(id).disabled = true; }); const additionalContainerCapabilities = await Promise.all(Object.entries(additionalContainerFiles).map(async ([extension, filePath]) => ({ extension, @@ -344,7 +435,8 @@ async function run() { `Disabled empty-player volume control still expands on hover: ${JSON.stringify({ emptyVolumeBefore, emptyVolumeAfter })}` ); if (process.env.TWITCH_VOD_MANAGER_CUTTER_EMPTY_ONLY === '1') { - console.log(JSON.stringify({ failures, runtimeIssues, cutterExportProfilePresentation, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter }, null, 2)); + check(runtimeIssues.length === 0, runtimeIssues.join('\n')); + console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter }, null, 2)); if (failures.length > 0) process.exitCode = 1; return; } @@ -418,41 +510,10 @@ async function run() { return video instanceof HTMLVideoElement && video.readyState >= HTMLMediaElement.HAVE_METADATA && document.querySelectorAll('#cutterThumbnailStrip img').length > 0 - && window.__cutterAssetAudit.waveformLoads.length > 0; + && window.__cutterAssetAudit.waveformLoads.length > 0 + && document.getElementById('cutterAudioStream').selectedOptions[0]?.textContent !== 'Keine Audiospur'; }, null, { timeout: 90000 }); await win.waitForTimeout(480); - const revealAnimation = await win.evaluate(() => { - const preview = document.getElementById('cutterPreview').getBoundingClientRect(); - const frames = window.__cutterRevealFrames; - return { - frames: frames.length, - runningFrames: frames.filter((frame) => frame.running).length, - distinctWidths: new Set(frames.map((frame) => frame.width.toFixed(1))).size, - distinctHeights: new Set(frames.map((frame) => frame.height.toFixed(1))).size, - intermediate: frames.some((frame) => { - const widthMin = Math.min(frames[0].width, preview.width); - const widthMax = Math.max(frames[0].width, preview.width); - const heightMin = Math.min(frames[0].height, preview.height); - const heightMax = Math.max(frames[0].height, preview.height); - return frame.width > widthMin + 2 && frame.width < widthMax - 2 - && frame.height > heightMin + 2 && frame.height < heightMax - 2; - }), - finalWidth: preview.width, - finalHeight: preview.height, - lastWidth: frames.at(-1)?.width ?? null, - lastHeight: frames.at(-1)?.height ?? null - }; - }); - check( - revealAnimation.frames >= 4 - && revealAnimation.runningFrames >= 3 - && revealAnimation.distinctWidths >= 3 - && revealAnimation.distinctHeights >= 3 - && revealAnimation.intermediate - && Math.abs(revealAnimation.lastWidth - revealAnimation.finalWidth) <= 2 - && Math.abs(revealAnimation.lastHeight - revealAnimation.finalHeight) <= 2, - `Empty-to-editor geometry does not glide through visible frames: ${JSON.stringify(revealAnimation)}` - ); const firstAssetQuality = await win.evaluate(async () => { const images = [...document.querySelectorAll('#cutterThumbnailStrip img')]; const strip = document.getElementById('cutterThumbnailStrip'); @@ -521,6 +582,268 @@ async function run() { initialAssetStability.thumbnailSets === 1 && initialAssetStability.waveformLoads === 1, `Timeline assets visibly switch quality before an explicit zoom: ${JSON.stringify(initialAssetStability)}` ); + await win.setViewportSize({ width: 1060, height: 700 }); + const loadedCompactLayout = await win.evaluate(() => { + const tab = document.getElementById('cutterTab'); + const workspace = document.getElementById('cutterWorkspace'); + const sidebar = document.querySelector('.cutter-sidebar').getBoundingClientRect(); + const preview = document.querySelector('.cutter-preview-panel').getBoundingClientRect(); + return { + horizontalOverflow: Math.max(tab.scrollWidth - tab.clientWidth, workspace.scrollWidth - workspace.clientWidth), + workspaceWidth: workspace.getBoundingClientRect().width, + sidebarWidth: sidebar.width, + previewWidth: preview.width, + singleColumn: Math.abs(sidebar.width - preview.width) <= 1 + }; + }); + check( + loadedCompactLayout.horizontalOverflow <= 1 + && loadedCompactLayout.workspaceWidth > 0 + && loadedCompactLayout.singleColumn, + `The wider cutter sidebar breaks the compact layout: ${JSON.stringify(loadedCompactLayout)}` + ); + await win.setViewportSize({ width: 1184, height: 661 }); + const loadedMinimumSource = await win.evaluate(() => { + const source = document.querySelector('.cutter-source-bar'); + const tab = document.getElementById('cutterTab'); + const workspace = document.getElementById('cutterWorkspace'); + const sidebar = document.querySelector('.cutter-sidebar').getBoundingClientRect(); + const preview = document.querySelector('.cutter-preview-panel').getBoundingClientRect(); + return { + display: getComputedStyle(source).display, + height: source.getBoundingClientRect().height, + workspaceShown: document.getElementById('cutterWorkspace').classList.contains('shown'), + videoReady: document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, + horizontalOverflow: Math.max(tab.scrollWidth - tab.clientWidth, workspace.scrollWidth - workspace.clientWidth), + sidebarWidth: sidebar.width, + previewWidth: preview.width + }; + }); + check( + loadedMinimumSource.workspaceShown + && loadedMinimumSource.videoReady + && loadedMinimumSource.display === 'none' + && loadedMinimumSource.height === 0 + && loadedMinimumSource.horizontalOverflow <= 1 + && loadedMinimumSource.sidebarWidth >= 300 + && loadedMinimumSource.previewWidth > loadedMinimumSource.sidebarWidth, + `The source selector remains visible after a real load at the native minimum viewport: ${JSON.stringify(loadedMinimumSource)}` + ); + const loadedCutterExportSelectPresentation = []; + for (const viewport of [{ width: 1184, height: 661 }, { width: 1280, height: 800 }]) { + await win.setViewportSize(viewport); + for (const theme of ['theme-twitch', 'theme-light']) { + await win.evaluate((className) => { document.body.className = className; }, theme); + for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) { + const select = win.locator(`#${id}`); + loadedCutterExportSelectPresentation.push(await select.evaluate((element, meta) => { + const style = getComputedStyle(element); + const context = document.createElement('canvas').getContext('2d'); + context.font = style.font; + const textWidth = context.measureText(element.selectedOptions[0]?.textContent || '').width; + const availableTextWidth = element.getBoundingClientRect().width + - Number.parseFloat(style.paddingLeft) + - Number.parseFloat(style.paddingRight) + - Number.parseFloat(style.borderLeftWidth) + - Number.parseFloat(style.borderRightWidth); + return { + ...meta, + text: element.selectedOptions[0]?.textContent || '', + backgroundImages: (style.backgroundImage.match(/url\(/g) || []).length, + backgroundRepeat: style.backgroundRepeat, + paddingRight: Number.parseFloat(style.paddingRight), + textWidth, + availableTextWidth + }; + }, { viewport, theme, id })); + } + } + } + check( + loadedCutterExportSelectPresentation.every((entry) => entry.backgroundImages === 1 + && entry.backgroundRepeat === 'no-repeat' + && entry.paddingRight >= 28 + && entry.textWidth <= entry.availableTextWidth + 0.5), + `Loaded cutter export selects have duplicate indicators or clipped text: ${JSON.stringify(loadedCutterExportSelectPresentation)}` + ); + await win.evaluate(() => { document.body.className = 'theme-twitch'; }); + await win.setViewportSize({ width: 1440, height: 900 }); + const revealAnimation = await win.evaluate(() => { + const preview = document.getElementById('cutterPreview').getBoundingClientRect(); + const frames = window.__cutterRevealFrames; + return { + frames: frames.length, + runningFrames: frames.filter((frame) => frame.running).length, + distinctWidths: new Set(frames.map((frame) => frame.width.toFixed(1))).size, + distinctHeights: new Set(frames.map((frame) => frame.height.toFixed(1))).size, + intermediate: frames.some((frame) => { + const widthMin = Math.min(frames[0].width, preview.width); + const widthMax = Math.max(frames[0].width, preview.width); + const heightMin = Math.min(frames[0].height, preview.height); + const heightMax = Math.max(frames[0].height, preview.height); + return frame.width > widthMin + 2 && frame.width < widthMax - 2 + && frame.height > heightMin + 2 && frame.height < heightMax - 2; + }), + finalWidth: preview.width, + finalHeight: preview.height, + lastWidth: frames.at(-1)?.width ?? null, + lastHeight: frames.at(-1)?.height ?? null + }; + }); + check( + revealAnimation.frames >= 4 + && revealAnimation.runningFrames >= 3 + && revealAnimation.distinctWidths >= 3 + && revealAnimation.distinctHeights >= 3 + && revealAnimation.intermediate + && Math.abs(revealAnimation.lastWidth - revealAnimation.finalWidth) <= 2 + && Math.abs(revealAnimation.lastHeight - revealAnimation.finalHeight) <= 2, + `Empty-to-editor geometry does not glide through visible frames: ${JSON.stringify(revealAnimation)}` + ); + const recoveryGeometry = await win.evaluate(async () => { + const panel = document.getElementById('cutterRecoveryPanel'); + const tab = document.getElementById('cutterTab'); + const container = document.querySelector('.cutter-container'); + const capture = () => { + const workspace = document.getElementById('cutterWorkspace').getBoundingClientRect(); + const preview = document.getElementById('cutterPreview').getBoundingClientRect(); + const timeline = document.getElementById('timelineContainer').getBoundingClientRect(); + return { + workspace: { left: workspace.left, top: workspace.top, width: workspace.width, height: workspace.height }, + preview: { left: preview.left, top: preview.top, width: preview.width, height: preview.height }, + timeline: { left: timeline.left, top: timeline.top, width: timeline.width, height: timeline.height }, + tabScrollTop: tab.scrollTop + }; + }; + panel.hidden = true; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const before = capture(); + panel.hidden = false; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const shown = capture(); + const panelRect = panel.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + panel.hidden = true; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const after = capture(); + const delta = (left, right) => Math.max(...['left', 'top', 'width', 'height'].map((key) => Math.abs(left[key] - right[key]))); + const workspaceShift = shown.workspace.top - before.workspace.top; + const workspaceShrink = before.workspace.height - shown.workspace.height; + return { + before, + shown, + after, + panelHeight: panelRect.height, + workspaceShift, + workspaceShrink, + workspaceBottomDelta: Math.abs((shown.workspace.top + shown.workspace.height) - (before.workspace.top + before.workspace.height)), + timelineShownDelta: delta(before.timeline, shown.timeline), + restoredDelta: Math.max(delta(before.workspace, after.workspace), delta(before.preview, after.preview), delta(before.timeline, after.timeline)), + scrollDelta: Math.max(Math.abs(shown.tabScrollTop - before.tabScrollTop), Math.abs(after.tabScrollTop - before.tabScrollTop)), + panelVisible: panelRect.width > 0 && panelRect.height > 0, + panelContained: panelRect.left >= containerRect.left - 1 && panelRect.right <= containerRect.right + 1 && panelRect.top >= containerRect.top - 1, + timelineVisible: shown.timeline.top >= tab.getBoundingClientRect().top - 1 && shown.timeline.top + shown.timeline.height <= Math.min(tab.getBoundingClientRect().bottom, window.innerHeight) + 1 + }; + }); + check( + recoveryGeometry.panelVisible + && recoveryGeometry.panelContained + && recoveryGeometry.timelineVisible + && recoveryGeometry.timelineShownDelta <= 1 + && recoveryGeometry.workspaceBottomDelta <= 1 + && recoveryGeometry.workspaceShift >= recoveryGeometry.panelHeight + 11 + && Math.abs(recoveryGeometry.workspaceShift - recoveryGeometry.workspaceShrink) <= 1 + && recoveryGeometry.restoredDelta <= 1 + && recoveryGeometry.scrollDelta <= 1, + `Cutter recovery shifts the loaded workspace or timeline: ${JSON.stringify(recoveryGeometry)}` + ); + const pngDropBefore = await win.evaluate(() => ({ + token: cutterFile?.token || null, + loadGeneration: cutterLoadGeneration, + mediaJobId: cutterMediaJobId, + editorState: JSON.stringify(cutterEditorState), + fileName: document.getElementById('cutterFilePath').value, + videoSource: document.getElementById('cutterVideo').src + })); + await dropCutterFile(win, unsupportedImageFile); + await win.waitForFunction(() => { + const toast = document.getElementById('appToast'); + return toast?.classList.contains('warn') && toast.classList.contains('show') && toast.textContent === UI_TEXT.cutter.unsupportedFile; + }); + const pngDropState = await win.evaluate((before) => { + const toast = document.getElementById('appToast'); + const after = { + token: cutterFile?.token || null, + loadGeneration: cutterLoadGeneration, + mediaJobId: cutterMediaJobId, + editorState: JSON.stringify(cutterEditorState), + fileName: document.getElementById('cutterFilePath').value, + videoSource: document.getElementById('cutterVideo').src + }; + return { + unchanged: Object.keys(before).every((key) => before[key] === after[key]), + before, + after, + warning: toast?.textContent || '', + warningRole: toast?.getAttribute('role') || '', + warningVisible: toast?.classList.contains('show') || false + }; + }, pngDropBefore); + check( + pngDropState.unchanged + && pngDropState.warningRole === 'alert' + && pngDropState.warningVisible, + `A real PNG drop reached the loader or changed the loaded editor: ${JSON.stringify(pngDropState)}` + ); + await app.evaluate(({ dialog }, pngPath) => { + const originalShowOpenDialog = dialog.showOpenDialog; + dialog.showOpenDialog = async () => { + dialog.showOpenDialog = originalShowOpenDialog; + return { canceled: false, filePaths: [pngPath] }; + }; + }, unsupportedImageFile); + const pngDialogState = await win.evaluate(async (before) => { + const toast = document.getElementById('appToast'); + toast?.classList.remove('show', 'warn'); + let rejected = false; + try { + await window.selectCutterVideo(); + } catch { + rejected = true; + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + const after = { + token: cutterFile?.token || null, + loadGeneration: cutterLoadGeneration, + mediaJobId: cutterMediaJobId, + editorState: JSON.stringify(cutterEditorState), + fileName: document.getElementById('cutterFilePath').value, + videoSource: document.getElementById('cutterVideo').src + }; + return { + rejected, + unchanged: Object.keys(before).every((key) => before[key] === after[key]), + discardDialogVisible: document.getElementById('cutterDiscardModal').classList.contains('show'), + warning: toast?.textContent || '', + warningRole: toast?.getAttribute('role') || '', + warningVisible: toast?.classList.contains('show') || false + }; + }, pngDropBefore); + check( + !pngDialogState.rejected + && pngDialogState.unchanged + && !pngDialogState.discardDialogVisible + && pngDialogState.warning === await win.evaluate(() => UI_TEXT.cutter.unsupportedFile) + && pngDialogState.warningRole === 'alert' + && pngDialogState.warningVisible, + `A PNG returned by the native dialog was not rejected defensively: ${JSON.stringify(pngDialogState)}` + ); + if (remaindersOnly) { + check(runtimeIssues.length === 0, runtimeIssues.join('\n')); + console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2)); + if (failures.length > 0) process.exitCode = 1; + return; + } await win.evaluate(() => window.updateCutterZoom(Number(document.getElementById('cutterZoom').max))); await win.waitForFunction(() => { const waveform = document.getElementById('cutterWaveform'); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index 3a7e61d..b5c75c4 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -15,9 +15,9 @@ function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.16', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.16', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.16', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === '1.0.17', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.17', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.17', `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?.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}`); @@ -65,7 +65,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://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(indexSource.includes('Version: v1.0.16'), 'initial version label is not 1.0.16'); +check(indexSource.includes('Version: v1.0.17'), 'initial version label is not 1.0.17'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index a529019..bf73223 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -229,6 +229,117 @@ async function run() { check(preflightAfterPathChange.english.result === 'No checks run yet.' && preflightAfterPathChange.english.badgeUnknown && preflightAfterPathChange.english.badge === 'System: Unknown', `Download path change kept a stale English system check: ${JSON.stringify(preflightAfterPathChange.english)}`); check(preflightAfterPathChange.german.result === 'Noch kein Check ausgeführt.' && preflightAfterPathChange.german.badgeUnknown && preflightAfterPathChange.german.badge === 'System: Unbekannt', `Download path change restored a stale German system check: ${JSON.stringify(preflightAfterPathChange.german)}`); + const configBeforeImportChecks = await win.evaluate(() => window.api.getConfig()); + await app.evaluate(({ ipcMain }, currentConfig) => { + ipcMain.removeHandler('run-preflight'); + ipcMain.handle('run-preflight', async () => ({ + ok: true, + autoFixApplied: false, + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true + }, + messages: [], + timestamp: '2026-01-01T00:00:00Z' + })); + ipcMain.removeHandler('import-config'); + ipcMain.handle('import-config', async () => ({ success: true, filePath: 'fixture.json' })); + ipcMain.removeHandler('get-config'); + const importedConfig = { ...currentConfig }; + delete importedConfig.language; + ipcMain.handle('get-config', async () => importedConfig); + }, configBeforeImportChecks); + const preflightAfterImportWithoutLanguage = await win.evaluate(async () => { + window.changeLanguage('en'); + await window.runPreflight(false); + await window.importConfigFromFile(); + return { + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '', + badgeUnknown: document.getElementById('healthBadge')?.classList.contains('unknown') || false + }; + }); + checks.preflightAfterImportWithoutLanguage = preflightAfterImportWithoutLanguage; + check(preflightAfterImportWithoutLanguage.result === 'No checks run yet.' && preflightAfterImportWithoutLanguage.badgeUnknown && preflightAfterImportWithoutLanguage.badge === 'System: Unknown', `Config import without a language field kept a stale system check: ${JSON.stringify(preflightAfterImportWithoutLanguage)}`); + + await app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('get-config'); + ipcMain.handle('get-config', async () => { + throw new Error('fixture get-config failure'); + }); + }); + const preflightAfterImportRefreshFailure = await win.evaluate(async () => { + await window.runPreflight(false); + await window.importConfigFromFile(); + return { + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '', + badgeUnknown: document.getElementById('healthBadge')?.classList.contains('unknown') || false + }; + }); + checks.preflightAfterImportRefreshFailure = preflightAfterImportRefreshFailure; + check(preflightAfterImportRefreshFailure.result === 'No checks run yet.' && preflightAfterImportRefreshFailure.badgeUnknown && preflightAfterImportRefreshFailure.badge === 'System: Unknown', `Config import lost its system-check invalidation when getConfig failed: ${JSON.stringify(preflightAfterImportRefreshFailure)}`); + + await app.evaluate(({ ipcMain }, currentConfig) => { + ipcMain.removeHandler('get-config'); + ipcMain.handle('get-config', async () => ({ ...currentConfig, language: 'de' })); + ipcMain.removeHandler('run-preflight'); + ipcMain.handle('run-preflight', () => new Promise((resolve) => { + globalThis.__workspaceImportedPreflightResolve = () => resolve({ + ok: true, + autoFixApplied: false, + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true + }, + messages: [], + timestamp: '2026-01-01T00:00:00Z' + }); + })); + }, configBeforeImportChecks); + await win.evaluate(() => { + window.changeLanguage('en'); + globalThis.__workspaceImportedPreflightPending = window.runPreflight(false); + }); + await app.evaluate(async () => { + const deadline = Date.now() + 5000; + while (typeof globalThis.__workspaceImportedPreflightResolve !== 'function') { + if (Date.now() >= deadline) throw new Error('Timed out waiting for the imported-config system check'); + await new Promise((resolve) => setTimeout(resolve, 1)); + } + }); + const preflightDuringImport = await win.evaluate(async () => { + await window.importConfigFromFile(); + return { + label: document.getElementById('btnPreflightRun')?.textContent || '', + disabled: document.getElementById('btnPreflightRun')?.disabled || false, + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '' + }; + }); + await app.evaluate(() => globalThis.__workspaceImportedPreflightResolve()); + const preflightAfterImportedRun = await win.evaluate(async () => { + await globalThis.__workspaceImportedPreflightPending; + return { + label: document.getElementById('btnPreflightRun')?.textContent || '', + disabled: document.getElementById('btnPreflightRun')?.disabled || false, + result: document.getElementById('preflightResult')?.textContent || '', + badge: document.getElementById('healthBadge')?.textContent || '', + badgeUnknown: document.getElementById('healthBadge')?.classList.contains('unknown') || false + }; + }); + checks.preflightImportInFlight = { during: preflightDuringImport, after: preflightAfterImportedRun }; + check(preflightDuringImport.label === 'Prüfe...' && preflightDuringImport.disabled, `Config import replaced the localized running system-check label: ${JSON.stringify(preflightDuringImport)}`); + check(preflightDuringImport.result === 'Noch kein Check ausgeführt.' && preflightDuringImport.badge === 'System: Unbekannt', `Config import did not invalidate the running system check immediately: ${JSON.stringify(preflightDuringImport)}`); + check(preflightAfterImportedRun.label === 'Check ausführen' && !preflightAfterImportedRun.disabled, `Imported-config system check did not finish with the current localized label: ${JSON.stringify(preflightAfterImportedRun)}`); + check(preflightAfterImportedRun.result === 'Noch kein Check ausgeführt.' && preflightAfterImportedRun.badgeUnknown && preflightAfterImportedRun.badge === 'System: Unbekannt', `Stale system-check result returned after config import: ${JSON.stringify(preflightAfterImportedRun)}`); + const mergeAddToolbarActions = await win.evaluate(() => { const capture = () => { const button = document.querySelector('[data-toolbar-for="merge"] button[onclick="addMergeFiles()"]'); @@ -880,6 +991,89 @@ async function run() { check(downloadSettingsNarrow.columns === 1, `Narrow Download Settings does not collapse to one column: ${downloadSettingsNarrow.columns}`); check(downloadSettingsNarrow.documentOverflow <= 1 && downloadSettingsNarrow.tabOverflow <= 1, `Narrow Download Settings causes horizontal overflow: ${JSON.stringify(downloadSettingsNarrow)}`); + await app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('get-runtime-metrics'); + ipcMain.handle('get-runtime-metrics', async () => ({ + cacheHits: 1, + cacheMisses: 2, + duplicateSkips: 0, + retriesScheduled: 4, + retriesExhausted: 1, + integrityFailures: 5, + downloadsStarted: 11, + downloadsCompleted: 12, + downloadsFailed: 13, + downloadedBytesTotal: 2048, + lastSpeedBytesPerSec: 2048, + avgSpeedBytesPerSec: 1024, + activeItemId: 'vod-42', + activeItemTitle: 'Fixture', + lastErrorClass: 'rate_limit', + lastRetryDelaySeconds: 14, + timestamp: '2026-01-01T12:34:56Z', + queue: { + pending: 2, + downloading: 3, + paused: 0, + completed: 0, + error: 2, + total: 7 + }, + caches: { + loginToUserId: 2, + vodList: 1, + clipInfo: 2 + }, + config: { + performanceMode: 'stability', + smartScheduler: false, + metadataCacheMinutes: 10, + duplicatePrevention: true + } + })); + }); + const captureRuntimeMetrics = async (language) => win.evaluate(async (nextLanguage) => { + window.changeLanguage(nextLanguage); + await window.refreshRuntimeMetrics(); + return { + title: document.getElementById('runtimeMetricsTitle')?.textContent?.trim() || '', + exportLabel: document.getElementById('btnExportMetrics')?.textContent?.trim() || '', + autoRefreshLabel: document.getElementById('runtimeMetricsAutoRefreshText')?.textContent?.trim() || '', + lines: (document.getElementById('runtimeMetricsOutput')?.textContent || '').split('\n') + }; + }, language); + const germanRuntimeMetrics = await captureRuntimeMetrics('de'); + const englishRuntimeMetrics = await captureRuntimeMetrics('en'); + await win.evaluate(() => window.changeLanguage('de')); + checks.runtimeMetricsLocalization = { german: germanRuntimeMetrics, english: englishRuntimeMetrics }; + const expectedGermanRuntimeLines = [ + 'Warteschlange: 7 insgesamt (2 ausstehend, 3 laufend, 2 fehlgeschlagen)', + 'Modus: Max Stabilität | Intelligente Planung: deaktiviert | Duplikatschutz: aktiviert', + 'Wiederholungen: 4 geplant, 1 ausgeschöpft', + 'Integritätsfehler: 5', + 'Zwischenspeicher: 1 Treffer, 2 Fehlzugriffe, 1 VOD, 2 Nutzer, 2 Clips', + 'Bandbreite: aktuell 2.0 KB/s, durchschnittlich 1.0 KB/s', + 'Downloads: 11 gestartet, 12 abgeschlossen, 13 fehlgeschlagen, 2.0 KB übertragen', + 'Aktiver Eintrag: Fixture (vod-42)', + 'Letzte Fehlerklasse: Anfragelimit, Wiederholungsverzögerung: 14 s' + ]; + const expectedEnglishRuntimeLines = [ + 'Queue: 7 total (2 pending, 3 downloading, 2 failed)', + 'Mode: Max Stability | Smart scheduler: disabled | Duplicate prevention: enabled', + 'Retries: 4 scheduled, 1 exhausted', + 'Integrity failures: 5', + 'Cache: 1 hit, 2 misses, 1 VOD, 2 users, 2 clips', + 'Bandwidth: current 2.0 KB/s, average 1.0 KB/s', + 'Downloads: 11 started, 12 completed, 13 failed, 2.0 KB transferred', + 'Active item: Fixture (vod-42)', + 'Last error class: Rate limit, retry delay: 14 s' + ]; + check(germanRuntimeMetrics.title === 'Laufzeitmetriken' && germanRuntimeMetrics.exportLabel === 'JSON exportieren' && germanRuntimeMetrics.autoRefreshLabel === 'Automatisch aktualisieren', `German runtime metrics controls are not fully localized: ${JSON.stringify(germanRuntimeMetrics)}`); + check(JSON.stringify(germanRuntimeMetrics.lines.slice(0, 9)) === JSON.stringify(expectedGermanRuntimeLines), `German runtime metrics output is not naturally localized: ${JSON.stringify(germanRuntimeMetrics.lines)}`); + check(!/\b(?:total|pending|downloading|failed|balanced|true|false|scheduled|exhausted|hits|misses|current|avg|started|done|bytes|retryDelay|smartScheduler|dedupe|network|rate_limit|auth|tooling|integrity|io|validation|unknown|Stabilitat)\b/i.test(germanRuntimeMetrics.lines.join('\n')), `German runtime metrics retain raw English fragments: ${germanRuntimeMetrics.lines.join(' | ')}`); + check(englishRuntimeMetrics.title === 'Runtime Metrics' && englishRuntimeMetrics.exportLabel === 'Export JSON' && englishRuntimeMetrics.autoRefreshLabel === 'Auto refresh', `English runtime metrics controls regressed: ${JSON.stringify(englishRuntimeMetrics)}`); + check(JSON.stringify(englishRuntimeMetrics.lines.slice(0, 9)) === JSON.stringify(expectedEnglishRuntimeLines), `English runtime metrics output regressed: ${JSON.stringify(englishRuntimeMetrics.lines)}`); + const diagnosticLayouts = []; for (const target of [TARGETS[2], TARGETS[1], TARGETS[0]]) { await win.setViewportSize(target); @@ -1483,17 +1677,17 @@ async function run() { await win.emulateMedia({ colorScheme: 'dark' }); await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); await win.waitForTimeout(260); - const darkTheme = await captureTheme(); + const darkTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; await win.locator('#workspaceThemePicker [data-theme="system"]').click(); await win.waitForTimeout(260); - const systemDarkTheme = await captureTheme(); + const systemDarkTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; await win.locator('#workspaceThemePicker [data-theme="light"]').click(); await win.waitForTimeout(260); - const lightTheme = await captureTheme(); + const lightTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; await win.emulateMedia({ colorScheme: 'light' }); await win.locator('#workspaceThemePicker [data-theme="system"]').click(); await win.waitForTimeout(260); - const systemLightTheme = await captureTheme(); + const systemLightTheme = { ...await captureTheme(), nativeThemeSource: await app.evaluate(({ nativeTheme }) => nativeTheme.themeSource) }; checks.themes = { darkTheme, systemDarkTheme, lightTheme, systemLightTheme }; for (const [name, theme] of Object.entries({ dark: darkTheme, systemDark: systemDarkTheme, light: lightTheme, systemLight: systemLightTheme })) { @@ -1507,6 +1701,9 @@ async function run() { check(systemDarkTheme.bodyClass === 'theme-system', `System-Dark theme body class is ${systemDarkTheme.bodyClass}`); check(lightTheme.bodyClass === 'theme-light', `Light theme body class is ${lightTheme.bodyClass}`); check(systemLightTheme.bodyClass === 'theme-system', `System theme body class is ${systemLightTheme.bodyClass}`); + check(darkTheme.nativeThemeSource === 'dark', `Explicit Dark sets Electron nativeTheme to ${darkTheme.nativeThemeSource}`); + check(lightTheme.nativeThemeSource === 'light', `Explicit Light sets Electron nativeTheme to ${lightTheme.nativeThemeSource}`); + check(systemDarkTheme.nativeThemeSource === 'system' && systemLightTheme.nativeThemeSource === 'system', `System theme is forced away from Electron/Windows: ${systemDarkTheme.nativeThemeSource}/${systemLightTheme.nativeThemeSource}`); check([darkTheme, systemDarkTheme].every((theme) => theme.checkboxColor === 'rgb(34, 197, 94)' && /23111111/i.test(theme.checkboxBackground)), `Dark checked Settings toggles do not use green with a black check: ${JSON.stringify({ darkTheme, systemDarkTheme })}`); check([lightTheme, systemLightTheme].every((theme) => theme.checkboxColor === theme.primaryColor && /23ffffff/i.test(theme.checkboxBackground)), `Light checked Settings toggles do not use the theme primary color with a white check: ${JSON.stringify({ lightTheme, systemLightTheme })}`); check(darkTheme.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background'); diff --git a/src/cutter-workspace-styles.production-path.test.ts b/src/cutter-workspace-styles.production-path.test.ts index 6621600..12100e2 100644 --- a/src/cutter-workspace-styles.production-path.test.ts +++ b/src/cutter-workspace-styles.production-path.test.ts @@ -6,14 +6,23 @@ const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8'); const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8'); describe('cutter workspace style production paths', () => { - test('hides the source bar when the non-adjacent workspace is shown', () => { - expect(styles).toContain('.cutter-source-bar:has(~ .cutter-workspace.shown)'); + test('keeps loaded-source visibility independent from the large-window media query', () => { + const selector = '#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown)'; + const selectorIndex = styles.indexOf(selector); + const mediaStart = styles.indexOf('@media (min-width: 1181px) and (min-height: 680px)'); + const nextTopLevelRule = styles.indexOf('\n.cutter-source-bar {', mediaStart); + expect(selectorIndex).toBeGreaterThan(-1); + expect(selectorIndex < mediaStart || selectorIndex > nextTopLevelRule).toBe(true); }); - test('keeps the export profile indicator from tiling after workspace background styling', () => { + test('keeps one reserved non-repeating indicator on every cutter export select', () => { const selector = '#cutterTab .cutter-export-options select {'; const start = workspaceStyles.indexOf(selector); const end = workspaceStyles.indexOf('}', start); - expect(workspaceStyles.slice(start, end)).toMatch(/background-repeat:\s*no-repeat/); + const rule = workspaceStyles.slice(start, end); + expect(rule.match(/background-image:/g)).toHaveLength(1); + expect(rule).toMatch(/appearance:\s*none/); + expect(rule).toMatch(/padding-right:\s*28px/); + expect(rule).toMatch(/background-repeat:\s*no-repeat/); }); }); diff --git a/src/index.html b/src/index.html index 869593a..87c8f1e 100644 --- a/src/index.html +++ b/src/index.html @@ -942,7 +942,7 @@ diff --git a/src/main.ts b/src/main.ts index 68167aa..8f3abe4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7081,8 +7081,13 @@ async function processQueue(manualOverride = false): Promise { // ========================================== // WINDOW CREATION // ========================================== +function resolveNativeThemeSource(theme: string): 'system' | 'light' | 'dark' { + if (theme === 'system') return 'system'; + return theme === 'light' ? 'light' : 'dark'; +} + function createWindow(): void { - nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark'; + nativeTheme.themeSource = resolveNativeThemeSource(config.theme); const windowIconPath = WINDOWS_APP_ICON_PATH ?? path.join(__dirname, '../build/icon.png'); mainWindow = new BrowserWindow({ @@ -7571,7 +7576,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability } if (config.theme !== previousTheme) { - nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark'; + nativeTheme.themeSource = resolveNativeThemeSource(config.theme); } if (config.persist_queue_on_restart === false) { diff --git a/src/main/queue/process-registry.test.ts b/src/main/queue/process-registry.test.ts index ed8c5a3..eee3c51 100644 --- a/src/main/queue/process-registry.test.ts +++ b/src/main/queue/process-registry.test.ts @@ -1,5 +1,7 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { QueueProcessRegistry, QueueRunLifecycle, type QueueProcessResource } from './process-registry'; +import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit, type QueueProcessResource } from './process-registry'; function deferred(): { promise: Promise; resolve: () => void } { let resolve!: () => void; @@ -20,6 +22,82 @@ function createResource(wait: Promise = Promise.resolve()): QueueProcessRe }; } +describe('waitForChildProcessExit', () => { + it('settles immediately on close without forcing termination', async () => { + vi.useFakeTimers(); + try { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + }) as unknown as ChildProcess; + let settled = false; + const waiting = waitForChildProcessExit(child, 25).then(() => { + settled = true; + }); + + child.emit('close', 0, null); + await waiting; + + expect(settled).toBe(true); + expect(child.kill).not.toHaveBeenCalled(); + expect(child.listenerCount('close')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('returns immediately for an already exited child without allocating wait resources', async () => { + vi.useFakeTimers(); + try { + const child = Object.assign(new EventEmitter(), { + exitCode: 0, + signalCode: null, + kill: vi.fn(() => true), + }) as unknown as ChildProcess; + + await waitForChildProcessExit(child, 25); + + expect(child.kill).not.toHaveBeenCalled(); + expect(child.listenerCount('close')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('settles and releases resources when close never arrives after forced termination', async () => { + vi.useFakeTimers(); + try { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + }) as unknown as ChildProcess; + let settled = false; + const waiting = waitForChildProcessExit(child, 25).then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(25); + + expect(child.kill).toHaveBeenCalledOnce(); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(25); + + expect(settled).toBe(true); + expect(child.listenerCount('close')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + await waiting; + } finally { + vi.useRealTimers(); + } + }); +}); + describe('QueueProcessRegistry', () => { it('keeps parallel queue item process groups independent', async () => { const registry = new QueueProcessRegistry(); diff --git a/src/main/queue/process-registry.ts b/src/main/queue/process-registry.ts index c4757fb..9110597 100644 --- a/src/main/queue/process-registry.ts +++ b/src/main/queue/process-registry.ts @@ -5,15 +5,27 @@ export type QueueProcessPhase = 'streamlink' | 'merge' | 'split' | 'post-process export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000): Promise { if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve(); return new Promise((resolve) => { + let forceKillTimer: ReturnType | null = null; + let settleTimer: ReturnType | null = null; + let settled = false; const finish = (): void => { - clearTimeout(timer); + if (settled) return; + settled = true; + if (forceKillTimer) clearTimeout(forceKillTimer); + if (settleTimer) clearTimeout(settleTimer); + process.removeListener('close', finish); resolve(); }; - const timer = setTimeout(() => { - if (process.exitCode !== null || process.signalCode !== null) return; + process.once('close', finish); + forceKillTimer = setTimeout(() => { + forceKillTimer = null; + if (process.exitCode !== null || process.signalCode !== null) { + finish(); + return; + } + settleTimer = setTimeout(finish, forceKillAfterMs); try { process.kill('SIGKILL'); } catch { } }, forceKillAfterMs); - process.once('close', finish); }); } diff --git a/src/renderer-cutter.production-path.test.ts b/src/renderer-cutter.production-path.test.ts index 6b5062a..a98b7c2 100644 --- a/src/renderer-cutter.production-path.test.ts +++ b/src/renderer-cutter.production-path.test.ts @@ -12,6 +12,18 @@ function sourceFragment(start: string, end: string): string { return source.slice(from, to); } +function streamerSourceFragment(start: string, end: string): string { + const source = readFileSync(join(__dirname, 'renderer-streamers.ts'), 'utf8'); + const from = source.indexOf(start); + const to = source.indexOf(end, from); + if (from < 0 || to < 0) throw new Error('Missing renderer streamers production fragment'); + return source.slice(from, to); +} + +function cutterFileValidationFragment(): string { + return streamerSourceFragment('function isSupportedCutterVideoFile', 'function initCutterDragDrop'); +} + function evaluate(source: string, context: Record, expose: string): Record unknown> { context.globalThis = context; context.window = context; @@ -22,7 +34,68 @@ function evaluate(source: string, context: Record, expose: stri return (context as { __cutterProductionPath: Record unknown> }).__cutterProductionPath; } +interface FakeOption { + value: string; + textContent: string; +} + +interface FakeSelect { + value: string; + disabled: boolean; + options: FakeOption[]; + replaceChildren(...children: FakeOption[]): void; + append(child: FakeOption): void; +} + +function createCutterSelects(): Map { + const createSelect = (): FakeSelect => ({ + value: '', + disabled: false, + options: [], + replaceChildren(...children) { this.options = [...children]; }, + append(child) { this.options.push(child); }, + }); + return new Map([ + ['cutterAudioStream', createSelect()], + ['cutterExportProfile', createSelect()], + ['cutterExportEncoder', createSelect()], + ]); +} + describe('cutter production paths', () => { + test('rejects a PNG drop before requesting a capability or loader', async () => { + const listeners = new Map) => Promise | void>(); + let capabilityRequests = 0; + let loadRequests = 0; + const toasts: Array<[string, string]> = []; + const tab = { + addEventListener: (name: string, listener: (event: Record) => Promise | void) => listeners.set(name, listener), + }; + const preview = { classList: { toggle: () => undefined } }; + const api = evaluate(`${cutterFileValidationFragment()}\n${streamerSourceFragment('function initCutterDragDrop', 'let streamerContextMenu')}`, { + document: { getElementById: (id: string) => id === 'cutterTab' ? tab : preview }, + UI_TEXT: { cutter: { unsupportedFile: 'unsupported' } }, + showAppToast: (message: string, type: string) => toasts.push([message, type]), + requestCutterVideoReplacement: async () => { loadRequests += 1; }, + api: { + selectDroppedVideo: async () => { + capabilityRequests += 1; + return { token: 'png-capability', name: 'frame.png' }; + }, + }, + }, 'initCutterDragDrop'); + api.initCutterDragDrop(); + + await listeners.get('drop')?.({ + dataTransfer: { files: [{ name: 'frame.png', type: 'image/png' }] }, + preventDefault: () => undefined, + }); + + expect(toasts).toEqual([['unsupported', 'warn']]); + expect(capabilityRequests).toBe(0); + expect(loadRequests).toBe(0); + }); + test('opens a saved project without first overwriting its autosave', async () => { let saves = 0; let opens = 0; @@ -69,6 +142,86 @@ describe('cutter production paths', () => { expect(saves).toBe(0); }); + test('keeps a recovered hardware encoder while export options are still loading', () => { + const selects = createCutterSelects(); + const context: Record = { + cutterEditorState: { duration: 90, fps: 30, trimStart: 0, trimEnd: 90, cuts: [] }, + cutterVideoInfo: { + duration: 90, + fps: 30, + audioStreams: [{ index: 0, language: 'deu', codec: 'aac', channels: 2 }], + }, + cutterExportProfile: 'balanced', + cutterExportEncoder: 'software', + cutterAudioStreamIndex: 0, + cutterExportOptions: undefined, + cutterHistoryPast: [], + cutterHistoryFuture: [], + cutterActiveCutId: null, + byId: (id: string) => selects.get(id), + document: { createElement: () => ({ value: '', textContent: '' }) }, + renderCutterEditor: () => undefined, + seekCutterVideo: () => undefined, + }; + const api = evaluate(sourceFragment('function updateCutterAudioStreams', 'async function recoverCutterProject'), context, 'applyCutterProject, updateCutterExportControls'); + + const applied = api.applyCutterProject({ + duration: 90, + fps: 30, + trimStart: 12, + trimEnd: 80, + cuts: [], + profile: 'balanced', + encoder: 'h264_nvenc', + audioStreamIndex: 0, + }); + + expect(applied).toBe(true); + expect(context.cutterExportEncoder).toBe('h264_nvenc'); + expect(selects.get('cutterExportEncoder')?.options.map((option) => option.value)).toContain('h264_nvenc'); + expect(selects.get('cutterExportEncoder')?.value).toBe('h264_nvenc'); + expect(selects.get('cutterExportEncoder')?.disabled).toBe(true); + + api.updateCutterExportControls({ + profiles: [ + { id: 'quality', label: 'Quality', container: 'mp4' }, + { id: 'balanced', label: 'Balanced', container: 'mp4' }, + { id: 'fast', label: 'Fast', container: 'mp4' }, + { id: 'archive', label: 'Archive', container: 'mkv' }, + ], + hardwareEncoders: ['h264_nvenc'], + }); + + expect(context.cutterExportEncoder).toBe('h264_nvenc'); + expect(selects.get('cutterExportEncoder')?.value).toBe('h264_nvenc'); + expect(selects.get('cutterExportEncoder')?.disabled).toBe(false); + }); + + test('falls back to software when the export-option probe finishes without options', async () => { + const file = { token: 'source-capability', name: 'source.mp4' }; + const selects = createCutterSelects(); + const context: Record = { + cutterExportProfile: 'balanced', + cutterExportEncoder: 'h264_nvenc', + cutterExportOptions: undefined, + cutterLoadGeneration: 4, + cutterFile: file, + byId: (id: string) => selects.get(id), + document: { createElement: () => ({ value: '', textContent: '' }) }, + api: { getCutterExportOptions: async () => { throw new Error('probe failed'); } }, + }; + const api = evaluate(sourceFragment('function updateCutterExportControls', 'function applyCutterProject'), context, 'updateCutterExportControls, loadCutterExportOptions'); + + api.updateCutterExportControls(undefined); + expect(context.cutterExportEncoder).toBe('h264_nvenc'); + + await api.loadCutterExportOptions(file, 4); + + expect(context.cutterExportEncoder).toBe('software'); + expect(selects.get('cutterExportEncoder')?.value).toBe('software'); + expect(selects.get('cutterExportEncoder')?.disabled).toBe(true); + }); + test('offers recovery before enabling edits or starting the encoder probe', async () => { const events: string[] = []; const elements = new Map>(); @@ -150,4 +303,36 @@ describe('cutter production paths', () => { expect(events.indexOf('recovery')).toBeLessThan(events.indexOf('enable')); expect(events.indexOf('offer')).toBeLessThan(events.indexOf('probe')); }); + + test('rejects an unsupported file returned by the video dialog before replacement', async () => { + let replacements = 0; + const toasts: Array<[string, string]> = []; + const api = evaluate(`${cutterFileValidationFragment()}\n${sourceFragment('async function selectCutterVideo', 'function updateTimeFromInput')}`, { + requestCutterVideoReplacement: async () => { replacements += 1; }, + showAppToast: (message: string, type: string) => toasts.push([message, type]), + UI_TEXT: { cutter: { unsupportedFile: 'unsupported' } }, + api: { selectVideoFile: async () => ({ token: 'png-capability', name: 'frame.png' }) }, + }, 'selectCutterVideo'); + + await api.selectCutterVideo(); + + expect(replacements).toBe(0); + expect(toasts).toEqual([['unsupported', 'warn']]); + }); + + test('turns a rejected video dialog request into an unsupported-file warning', async () => { + let replacements = 0; + const toasts: Array<[string, string]> = []; + const api = evaluate(`${cutterFileValidationFragment()}\n${sourceFragment('async function selectCutterVideo', 'function updateTimeFromInput')}`, { + requestCutterVideoReplacement: async () => { replacements += 1; }, + showAppToast: (message: string, type: string) => toasts.push([message, type]), + UI_TEXT: { cutter: { unsupportedFile: 'unsupported' } }, + api: { selectVideoFile: async () => { throw new Error('invalid dialog selection'); } }, + }, 'selectCutterVideo'); + + await api.selectCutterVideo(); + + expect(replacements).toBe(0); + expect(toasts).toEqual([['unsupported', 'warn']]); + }); }); diff --git a/src/renderer-cutter.ts b/src/renderer-cutter.ts index 407bf57..ffd833d 100644 --- a/src/renderer-cutter.ts +++ b/src/renderer-cutter.ts @@ -66,7 +66,7 @@ let cutterExportEncoder: 'software' | 'h264_nvenc' | 'h264_qsv' | 'h264_amf' = ' let cutterAudioStreamIndex = 0; let cutterPendingProject: CutterProject | null = null; let cutterAutosaveTimer: number | null = null; -let cutterExportOptions: CutterExportOptions | null = null; +let cutterExportOptions: CutterExportOptions | null | undefined; let cutterRecoveryDecisionPending = false; const cutterMaximumCuts = 64; const cutterFrameTolerance = 1e-8; @@ -212,7 +212,7 @@ function updateCutterAudioStreams(): void { select.disabled = false; } -function updateCutterExportControls(options: CutterExportOptions | null): void { +function updateCutterExportControls(options: CutterExportOptions | null | undefined): void { const profile = byId('cutterExportProfile'); const encoder = byId('cutterExportEncoder'); if (options) { @@ -230,16 +230,18 @@ function updateCutterExportControls(options: CutterExportOptions | null): void { software.textContent = 'Software'; encoder.append(software); if (cutterExportProfile !== 'archive') { - (options?.hardwareEncoders ?? []).forEach((value) => { + const hardwareEncoders = options?.hardwareEncoders + ?? (options === undefined && cutterExportEncoder !== 'software' ? [cutterExportEncoder] : []); + hardwareEncoders.forEach((value) => { const option = document.createElement('option'); option.value = value; option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF'; encoder.append(option); }); } - if (!Array.from(encoder.options).some((option) => option.value === cutterExportEncoder)) cutterExportEncoder = 'software'; + if (cutterExportProfile === 'archive' || (options !== undefined && !Array.from(encoder.options).some((option) => option.value === cutterExportEncoder))) cutterExportEncoder = 'software'; encoder.value = cutterExportEncoder; - encoder.disabled = cutterExportProfile === 'archive'; + encoder.disabled = !options || cutterExportProfile === 'archive'; } async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise { @@ -914,7 +916,7 @@ function setCutterControlsEnabled(enabled: boolean): void { byId('cutterSaveProjectBtn').disabled = !enabled; byId('cutterOpenProjectBtn').disabled = !enabled; byId('cutterExportProfile').disabled = !enabled; - byId('cutterExportEncoder').disabled = !enabled || cutterExportProfile === 'archive'; + byId('cutterExportEncoder').disabled = !enabled || !cutterExportOptions || cutterExportProfile === 'archive'; byId('cutterAudioStream').disabled = !enabled || (cutterVideoInfo?.audioStreams.length ?? 0) === 0; const volumeControl = document.querySelector('.cutter-volume-control'); volumeControl?.classList.toggle('disabled', !enabled); @@ -1072,6 +1074,7 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise cutterActiveCutId = null; cutterExportProfile = 'balanced'; cutterExportEncoder = 'software'; + cutterExportOptions = undefined; cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0; cutterRecoveryDecisionPending = true; renderCutterProjectRecovery(null); @@ -1160,8 +1163,19 @@ async function requestCutterVideoReplacement(file: FileCapabilityReference): Pro } async function selectCutterVideo(): Promise { - const file = await window.api.selectVideoFile(); - if (file) await requestCutterVideoReplacement(file); + let file: FileCapabilityReference | null; + try { + file = await window.api.selectVideoFile(); + } catch { + showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn'); + return; + } + if (!file) return; + if (!isSupportedCutterVideoFile(file)) { + showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn'); + return; + } + await requestCutterVideoReplacement(file); } function updateTimeFromInput(): void { diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index dcdaa60..ebcc702 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -85,7 +85,7 @@ const UI_TEXT_DE = { parallelDownloads1: '1 (Standard)', parallelDownloads2: '2 (Parallel)', performanceModeLabel: 'Performance-Profil', - performanceModeStability: 'Max Stabilitat', + performanceModeStability: 'Max Stabilität', performanceModeBalanced: 'Ausgewogen', performanceModeSpeed: 'Max Geschwindigkeit', smartSchedulerLabel: 'Smart Queue Scheduler aktivieren', @@ -264,24 +264,51 @@ const UI_TEXT_DE = { templateGuideContextParts: 'Kontext: Beispiel für VOD-Teil', templateGuideContextClip: 'Kontext: Beispiel für Clip-Zuschnitt', templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog', - runtimeMetricsTitle: 'Runtime Metrics', + runtimeMetricsTitle: 'Laufzeitmetriken', runtimeMetricsRefresh: 'Aktualisieren', - runtimeMetricsExport: 'Export JSON', - runtimeMetricsAutoRefresh: 'Auto-Refresh', - runtimeMetricsLoading: 'Metriken werden geladen...', - runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.', - runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.', - runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.', - runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.', - runtimeMetricQueue: 'Queue', + runtimeMetricsExport: 'JSON exportieren', + runtimeMetricsAutoRefresh: 'Automatisch aktualisieren', + runtimeMetricsLoading: 'Laufzeitmetriken werden geladen...', + runtimeMetricsError: 'Laufzeitmetriken konnten nicht geladen werden.', + runtimeMetricsExportDone: 'Laufzeitmetriken wurden als JSON exportiert.', + runtimeMetricsExportCancelled: 'Export der Laufzeitmetriken abgebrochen.', + runtimeMetricsExportFailed: 'Export der Laufzeitmetriken fehlgeschlagen.', + runtimeMetricQueue: 'Warteschlange', + runtimeMetricQueueSummary: '{total} insgesamt ({pending} ausstehend, {downloading} laufend, {failed} fehlgeschlagen)', runtimeMetricMode: 'Modus', - runtimeMetricRetries: 'Retries', - runtimeMetricIntegrity: 'Integritatsfehler', - runtimeMetricCache: 'Cache', + runtimeMetricModeSummary: '{mode} | Intelligente Planung: {smartScheduler} | Duplikatschutz: {duplicatePrevention}', + runtimeMetricEnabled: 'aktiviert', + runtimeMetricDisabled: 'deaktiviert', + runtimeMetricRetries: 'Wiederholungen', + runtimeMetricRetriesSummary: '{scheduled} geplant, {exhausted} ausgeschöpft', + runtimeMetricIntegrity: 'Integritätsfehler', + runtimeMetricCache: 'Zwischenspeicher', + runtimeMetricCacheSummary: '{hits}, {misses}, {vods}, {users}, {clips}', + runtimeMetricCacheHitOne: '{count} Treffer', + runtimeMetricCacheHitMany: '{count} Treffer', + runtimeMetricCacheMissOne: '{count} Fehlzugriff', + runtimeMetricCacheMissMany: '{count} Fehlzugriffe', + runtimeMetricCacheVodOne: '{count} VOD', + runtimeMetricCacheVodMany: '{count} VODs', + runtimeMetricCacheUserOne: '{count} Nutzer', + runtimeMetricCacheUserMany: '{count} Nutzer', + runtimeMetricCacheClipOne: '{count} Clip', + runtimeMetricCacheClipMany: '{count} Clips', runtimeMetricBandwidth: 'Bandbreite', + runtimeMetricBandwidthSummary: 'aktuell {current}/s, durchschnittlich {average}/s', runtimeMetricDownloads: 'Downloads', - runtimeMetricActive: 'Aktiver Job', + runtimeMetricDownloadsSummary: '{started} gestartet, {completed} abgeschlossen, {failed} fehlgeschlagen, {bytes} übertragen', + runtimeMetricActive: 'Aktiver Eintrag', runtimeMetricLastError: 'Letzte Fehlerklasse', + runtimeMetricLastErrorSummary: '{errorClass}, Wiederholungsverzögerung: {retryDelay} s', + runtimeMetricErrorNetwork: 'Netzwerk', + runtimeMetricErrorRateLimit: 'Anfragelimit', + runtimeMetricErrorAuth: 'Authentifizierung', + runtimeMetricErrorTooling: 'Externe Tools', + runtimeMetricErrorIntegrity: 'Integrität', + runtimeMetricErrorIo: 'Dateisystem', + runtimeMetricErrorValidation: 'Validierung', + runtimeMetricErrorUnknown: 'Unbekannt', runtimeMetricUpdated: 'Aktualisiert', updateTitle: 'Updates', checkUpdates: 'Nach Updates suchen', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 83c90c3..8eef8b0 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -274,14 +274,41 @@ const UI_TEXT_EN = { runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.', runtimeMetricsExportFailed: 'Runtime metrics export failed.', runtimeMetricQueue: 'Queue', + runtimeMetricQueueSummary: '{total} total ({pending} pending, {downloading} downloading, {failed} failed)', runtimeMetricMode: 'Mode', + runtimeMetricModeSummary: '{mode} | Smart scheduler: {smartScheduler} | Duplicate prevention: {duplicatePrevention}', + runtimeMetricEnabled: 'enabled', + runtimeMetricDisabled: 'disabled', runtimeMetricRetries: 'Retries', + runtimeMetricRetriesSummary: '{scheduled} scheduled, {exhausted} exhausted', runtimeMetricIntegrity: 'Integrity failures', runtimeMetricCache: 'Cache', + runtimeMetricCacheSummary: '{hits}, {misses}, {vods}, {users}, {clips}', + runtimeMetricCacheHitOne: '{count} hit', + runtimeMetricCacheHitMany: '{count} hits', + runtimeMetricCacheMissOne: '{count} miss', + runtimeMetricCacheMissMany: '{count} misses', + runtimeMetricCacheVodOne: '{count} VOD', + runtimeMetricCacheVodMany: '{count} VODs', + runtimeMetricCacheUserOne: '{count} user', + runtimeMetricCacheUserMany: '{count} users', + runtimeMetricCacheClipOne: '{count} clip', + runtimeMetricCacheClipMany: '{count} clips', runtimeMetricBandwidth: 'Bandwidth', + runtimeMetricBandwidthSummary: 'current {current}/s, average {average}/s', runtimeMetricDownloads: 'Downloads', + runtimeMetricDownloadsSummary: '{started} started, {completed} completed, {failed} failed, {bytes} transferred', runtimeMetricActive: 'Active item', runtimeMetricLastError: 'Last error class', + runtimeMetricLastErrorSummary: '{errorClass}, retry delay: {retryDelay} s', + runtimeMetricErrorNetwork: 'Network', + runtimeMetricErrorRateLimit: 'Rate limit', + runtimeMetricErrorAuth: 'Authentication', + runtimeMetricErrorTooling: 'External tools', + runtimeMetricErrorIntegrity: 'Integrity', + runtimeMetricErrorIo: 'File system', + runtimeMetricErrorValidation: 'Validation', + runtimeMetricErrorUnknown: 'Unknown', runtimeMetricUpdated: 'Updated', updateTitle: 'Updates', checkUpdates: 'Check for updates', diff --git a/src/renderer-settings-autosave.test.ts b/src/renderer-settings-autosave.test.ts index 76e5abb..7441019 100644 --- a/src/renderer-settings-autosave.test.ts +++ b/src/renderer-settings-autosave.test.ts @@ -25,6 +25,19 @@ function createInput(value = '', checked = false): Input { return { value, checked }; } +function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string | null) => string { + const localeName = language === 'de' ? 'UI_TEXT_DE' : 'UI_TEXT_EN'; + const localeSource = fs.readFileSync(path.join(process.cwd(), 'src', `renderer-locale-${language}.ts`), 'utf8'); + const settingsSource = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); + const compiled = ts.transpileModule( + `${localeSource}\nlet UI_TEXT = ${localeName};\n${settingsSource}\nglobalThis.__getRuntimeErrorClassLabel = getRuntimeErrorClassLabel;`, + { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } } + ).outputText; + const context = vm.createContext({ console, window: {} }); + vm.runInContext(compiled, context); + return context.__getRuntimeErrorClassLabel as (errorClass: string | null) => string; +} + describe('renderer settings autosave orchestration', () => { it('persists a pure download policy change through the real autosave fingerprint', async () => { const inputs = new Map(inputIds.map((id) => [id, createInput()])); @@ -128,3 +141,25 @@ describe('renderer settings autosave orchestration', () => { expect(saveConfigCalls).toHaveLength(2); }); }); + +describe('renderer runtime metrics localization', () => { + it('renders every runtime error class and unknown values as human-readable German and English labels', () => { + const german = loadRuntimeErrorFormatter('de'); + const english = loadRuntimeErrorFormatter('en'); + const cases = [ + { errorClass: 'network', german: 'Netzwerk', english: 'Network' }, + { errorClass: 'rate_limit', german: 'Anfragelimit', english: 'Rate limit' }, + { errorClass: 'auth', german: 'Authentifizierung', english: 'Authentication' }, + { errorClass: 'tooling', german: 'Externe Tools', english: 'External tools' }, + { errorClass: 'integrity', german: 'Integrität', english: 'Integrity' }, + { errorClass: 'io', german: 'Dateisystem', english: 'File system' }, + { errorClass: 'validation', german: 'Validierung', english: 'Validation' }, + { errorClass: 'unknown', german: 'Unbekannt', english: 'Unknown' }, + { errorClass: 'future_error_class', german: 'Unbekannt', english: 'Unknown' }, + { errorClass: null, german: '-', english: '-' } + ]; + + expect(cases.map(({ errorClass }) => german(errorClass))).toEqual(cases.map(({ german: label }) => label)); + expect(cases.map(({ errorClass }) => english(errorClass))).toEqual(cases.map(({ english: label }) => label)); + }); +}); diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 99b0fc3..ad49216 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -55,6 +55,45 @@ function formatBytesForMetrics(bytes: number): string { return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; } +function formatRuntimeMetricSummary(template: string, values: Record): string { + return Object.entries(values).reduce( + (summary, [key, value]) => summary.replace(`{${key}}`, String(value)), + template + ); +} + +function getRuntimePerformanceModeLabel(mode: RuntimeMetricsSnapshot['config']['performanceMode']): string { + const labels: Record = { + stability: UI_TEXT.static.performanceModeStability, + balanced: UI_TEXT.static.performanceModeBalanced, + speed: UI_TEXT.static.performanceModeSpeed + }; + return labels[mode]; +} + +function getRuntimeBooleanLabel(value: boolean): string { + return value ? UI_TEXT.static.runtimeMetricEnabled : UI_TEXT.static.runtimeMetricDisabled; +} + +function formatRuntimeMetricCount(count: number, singular: string, plural: string): string { + return formatRuntimeMetricSummary(count === 1 ? singular : plural, { count }); +} + +function getRuntimeErrorClassLabel(errorClass: string | null): string { + if (!errorClass) return '-'; + const labels: Record = { + network: UI_TEXT.static.runtimeMetricErrorNetwork, + rate_limit: UI_TEXT.static.runtimeMetricErrorRateLimit, + auth: UI_TEXT.static.runtimeMetricErrorAuth, + tooling: UI_TEXT.static.runtimeMetricErrorTooling, + integrity: UI_TEXT.static.runtimeMetricErrorIntegrity, + io: UI_TEXT.static.runtimeMetricErrorIo, + validation: UI_TEXT.static.runtimeMetricErrorValidation, + unknown: UI_TEXT.static.runtimeMetricErrorUnknown + }; + return labels[errorClass] ?? UI_TEXT.static.runtimeMetricErrorUnknown; +} + function validateFilenameTemplates(showAlert = false): boolean { const templates = [ byId('vodFilenameTemplate').value.trim(), @@ -123,15 +162,44 @@ async function refreshRuntimeMetrics(showLoading = true): Promise { try { const metrics = await window.api.getRuntimeMetrics(); const lines = [ - `${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`, - `${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`, - `${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`, + `${UI_TEXT.static.runtimeMetricQueue}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricQueueSummary, { + total: metrics.queue.total, + pending: metrics.queue.pending, + downloading: metrics.queue.downloading, + failed: metrics.queue.error + })}`, + `${UI_TEXT.static.runtimeMetricMode}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricModeSummary, { + mode: getRuntimePerformanceModeLabel(metrics.config.performanceMode), + smartScheduler: getRuntimeBooleanLabel(metrics.config.smartScheduler), + duplicatePrevention: getRuntimeBooleanLabel(metrics.config.duplicatePrevention) + })}`, + `${UI_TEXT.static.runtimeMetricRetries}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricRetriesSummary, { + scheduled: metrics.retriesScheduled, + exhausted: metrics.retriesExhausted + })}`, `${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`, - `${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`, - `${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`, - `${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`, + `${UI_TEXT.static.runtimeMetricCache}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricCacheSummary, { + hits: formatRuntimeMetricCount(metrics.cacheHits, UI_TEXT.static.runtimeMetricCacheHitOne, UI_TEXT.static.runtimeMetricCacheHitMany), + misses: formatRuntimeMetricCount(metrics.cacheMisses, UI_TEXT.static.runtimeMetricCacheMissOne, UI_TEXT.static.runtimeMetricCacheMissMany), + vods: formatRuntimeMetricCount(metrics.caches.vodList, UI_TEXT.static.runtimeMetricCacheVodOne, UI_TEXT.static.runtimeMetricCacheVodMany), + users: formatRuntimeMetricCount(metrics.caches.loginToUserId, UI_TEXT.static.runtimeMetricCacheUserOne, UI_TEXT.static.runtimeMetricCacheUserMany), + clips: formatRuntimeMetricCount(metrics.caches.clipInfo, UI_TEXT.static.runtimeMetricCacheClipOne, UI_TEXT.static.runtimeMetricCacheClipMany) + })}`, + `${UI_TEXT.static.runtimeMetricBandwidth}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricBandwidthSummary, { + current: formatBytesForMetrics(metrics.lastSpeedBytesPerSec), + average: formatBytesForMetrics(metrics.avgSpeedBytesPerSec) + })}`, + `${UI_TEXT.static.runtimeMetricDownloads}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricDownloadsSummary, { + started: metrics.downloadsStarted, + completed: metrics.downloadsCompleted, + failed: metrics.downloadsFailed, + bytes: formatBytesForMetrics(metrics.downloadedBytesTotal) + })}`, `${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`, - `${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`, + `${UI_TEXT.static.runtimeMetricLastError}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricLastErrorSummary, { + errorClass: getRuntimeErrorClassLabel(metrics.lastErrorClass), + retryDelay: metrics.lastRetryDelaySeconds + })}`, `${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}` ]; @@ -591,12 +659,12 @@ async function importConfigFromFile(): Promise { const result = await window.api.importConfig(); const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; if (result.success) { + invalidatePreflightResult(); // Reload local config copy + refresh forms / streamer list / VOD grid try { config = await window.api.getConfig(); if (typeof setLanguage === 'function' && typeof config.language === 'string') { setLanguage(config.language); - invalidatePreflightResult(); } if (typeof renderStreamers === 'function') renderStreamers(); if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig(); @@ -604,6 +672,7 @@ async function importConfigFromFile(): Promise { renderVodGridFromCurrentState(); } } catch { /* ignore — next refresh will catch up */ } + refreshLocalizedPreflightUi(); if (toast) toast(UI_TEXT.static.configImported, 'info'); } else if (result.cancelled) { // User cancelled the dialog — no toast needed. diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 4f77149..131aafd 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -452,6 +452,10 @@ function initVodScrollTracking(): void { }, { passive: true }); } +function isSupportedCutterVideoFile(file: { name: string }): boolean { + return /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i.test(file.name); +} + function initCutterDragDrop(): void { const tab = document.getElementById('cutterTab'); if (!tab) return; @@ -485,8 +489,7 @@ function initCutterDragDrop(): void { const files = Array.from(e.dataTransfer.files || []); if (files.length === 0) return; - const allowed = /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i; - const file = files.find((entry) => allowed.test(entry.name)); + const file = files.find(isSupportedCutterVideoFile); if (!file) { showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn'); return; diff --git a/src/styles.css b/src/styles.css index 09faaf2..a92d48d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3086,6 +3086,10 @@ input[type="checkbox"].vod-select-checkbox { gap: 12px; } +#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) { + display: none; +} + @media (min-width: 1181px) and (min-height: 680px) { #cutterTab { overflow-y: hidden; @@ -3101,8 +3105,8 @@ input[type="checkbox"].vod-select-checkbox { grid-template-rows: minmax(0, 1fr) auto; } - #cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) { - display: none; + #cutterTab .cutter-container:has(.cutter-workspace.shown):has(> .cutter-recovery-panel:not([hidden])) { + grid-template-rows: auto minmax(0, 1fr) auto; } #cutterTab .cutter-workspace { @@ -3214,7 +3218,7 @@ input[type="checkbox"].vod-select-checkbox { .cutter-workspace { display: grid; - grid-template-columns: 280px minmax(0, 1fr); + grid-template-columns: 300px minmax(0, 1fr); gap: 12px; min-height: 420px; }