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; }