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.
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.16 - 2026-08-13
|
||||||
|
|
||||||
- Preserve completed System Check results when switching between German and English.
|
- Preserve completed System Check results when switching between German and English.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
|
1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
|
||||||
2. Download `Twitch-VOD-Manager-Setup-1.0.16.exe`.
|
2. Download `Twitch-VOD-Manager-Setup-1.0.17.exe`.
|
||||||
3. Run the installer and choose the installation directory.
|
3. Run the installer and choose the installation directory.
|
||||||
4. Start Twitch VOD Manager and add a streamer.
|
4. Start Twitch VOD Manager and add a streamer.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "1.0.16",
|
"version": "1.0.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "1.0.16",
|
"version": "1.0.17",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "1.0.16",
|
"version": "1.0.17",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
+1
-1
@@ -93,7 +93,7 @@ if (process.platform === 'win32') {
|
|||||||
sourcePath: electronSourceExecutable,
|
sourcePath: electronSourceExecutable,
|
||||||
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
|
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
|
||||||
iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
|
iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
|
||||||
version: '1.0.16',
|
version: '1.0.17',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+383
-60
@@ -75,6 +75,29 @@ async function loadCutterCapability(win, filePath) {
|
|||||||
return capability;
|
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) {
|
function createTestVideo(environment) {
|
||||||
const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4');
|
const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4');
|
||||||
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
||||||
@@ -143,6 +166,12 @@ function createUnsupportedVideo(environment, sourceFile) {
|
|||||||
return filePath;
|
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) {
|
function createLongVideo(environment) {
|
||||||
const filePath = path.join(environment.mediaDir, 'Long Cutter Test.mp4');
|
const filePath = path.join(environment.mediaDir, 'Long Cutter Test.mp4');
|
||||||
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
||||||
@@ -157,17 +186,19 @@ function createLongVideo(environment) {
|
|||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' });
|
const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' });
|
||||||
|
const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1';
|
||||||
const inputFile = createTestVideo(environment);
|
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
|
? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA
|
||||||
: createScrubStressVideo(environment);
|
: 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
|
? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA
|
||||||
: createMediumVideo(environment);
|
: createMediumVideo(environment);
|
||||||
const additionalContainerFiles = createAdditionalContainerVideos(environment, inputFile);
|
const additionalContainerFiles = remaindersOnly ? {} : createAdditionalContainerVideos(environment, inputFile);
|
||||||
const unsupportedInputFile = createUnsupportedVideo(environment, inputFile);
|
const unsupportedInputFile = remaindersOnly ? null : createUnsupportedVideo(environment, inputFile);
|
||||||
const silentInputFile = createSilentPortraitVideo(environment);
|
const unsupportedImageFile = createUnsupportedImage(environment);
|
||||||
const longInputFile = createLongVideo(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 outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4');
|
||||||
const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4');
|
const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4');
|
||||||
const failures = [];
|
const failures = [];
|
||||||
@@ -200,33 +231,93 @@ async function run() {
|
|||||||
await win.setViewportSize({ width: 1440, height: 900 });
|
await win.setViewportSize({ width: 1440, height: 900 });
|
||||||
await win.emulateMedia({ reducedMotion: 'reduce' });
|
await win.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
await win.evaluate(() => window.showTab('cutter'));
|
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(() => {
|
await win.evaluate(() => {
|
||||||
document.getElementById('cutterWorkspace').classList.add('shown');
|
document.getElementById('cutterWorkspace').classList.add('shown');
|
||||||
document.getElementById('cutterExportProfile').disabled = false;
|
for (const id of ['cutterExportProfile', 'cutterExportEncoder', 'cutterAudioStream']) document.getElementById(id).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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
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(
|
check(
|
||||||
cutterExportProfilePresentation.backgroundImage !== 'none'
|
cutterExportSelectPresentation.every((entry) => entry.appearance === 'none'
|
||||||
&& cutterExportProfilePresentation.backgroundRepeat === 'no-repeat'
|
&& entry.backgroundImages === 1
|
||||||
&& cutterExportProfilePresentation.backgroundPosition.includes('8px')
|
&& entry.backgroundRepeat === 'no-repeat'
|
||||||
&& cutterExportProfilePresentation.sourceDisplay === 'none',
|
&& entry.backgroundPosition.includes('8px')
|
||||||
`Export profile indicator is tiled or misplaced: ${JSON.stringify(cutterExportProfilePresentation)}`
|
&& 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(() => {
|
await win.evaluate(() => {
|
||||||
|
document.body.className = 'theme-twitch';
|
||||||
document.getElementById('cutterWorkspace').classList.remove('shown');
|
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]) => ({
|
const additionalContainerCapabilities = await Promise.all(Object.entries(additionalContainerFiles).map(async ([extension, filePath]) => ({
|
||||||
extension,
|
extension,
|
||||||
@@ -344,7 +435,8 @@ async function run() {
|
|||||||
`Disabled empty-player volume control still expands on hover: ${JSON.stringify({ emptyVolumeBefore, emptyVolumeAfter })}`
|
`Disabled empty-player volume control still expands on hover: ${JSON.stringify({ emptyVolumeBefore, emptyVolumeAfter })}`
|
||||||
);
|
);
|
||||||
if (process.env.TWITCH_VOD_MANAGER_CUTTER_EMPTY_ONLY === '1') {
|
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;
|
if (failures.length > 0) process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -418,41 +510,10 @@ async function run() {
|
|||||||
return video instanceof HTMLVideoElement
|
return video instanceof HTMLVideoElement
|
||||||
&& video.readyState >= HTMLMediaElement.HAVE_METADATA
|
&& video.readyState >= HTMLMediaElement.HAVE_METADATA
|
||||||
&& document.querySelectorAll('#cutterThumbnailStrip img').length > 0
|
&& 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 });
|
}, null, { timeout: 90000 });
|
||||||
await win.waitForTimeout(480);
|
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 firstAssetQuality = await win.evaluate(async () => {
|
||||||
const images = [...document.querySelectorAll('#cutterThumbnailStrip img')];
|
const images = [...document.querySelectorAll('#cutterThumbnailStrip img')];
|
||||||
const strip = document.getElementById('cutterThumbnailStrip');
|
const strip = document.getElementById('cutterThumbnailStrip');
|
||||||
@@ -521,6 +582,268 @@ async function run() {
|
|||||||
initialAssetStability.thumbnailSets === 1 && initialAssetStability.waveformLoads === 1,
|
initialAssetStability.thumbnailSets === 1 && initialAssetStability.waveformLoads === 1,
|
||||||
`Timeline assets visibly switch quality before an explicit zoom: ${JSON.stringify(initialAssetStability)}`
|
`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.evaluate(() => window.updateCutterZoom(Number(document.getElementById('cutterZoom').max)));
|
||||||
await win.waitForFunction(() => {
|
await win.waitForFunction(() => {
|
||||||
const waveform = document.getElementById('cutterWaveform');
|
const waveform = document.getElementById('cutterWaveform');
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ function check(condition, message) {
|
|||||||
if (!condition) failures.push(message);
|
if (!condition) failures.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
check(packageJson.version === '1.0.16', `package version is ${packageJson.version}`);
|
check(packageJson.version === '1.0.17', `package version is ${packageJson.version}`);
|
||||||
check(packageLock.version === '1.0.16', `lockfile version is ${packageLock.version}`);
|
check(packageLock.version === '1.0.17', `lockfile version is ${packageLock.version}`);
|
||||||
check(packageLock.packages?.['']?.version === '1.0.16', `lockfile root package version is ${packageLock.packages?.['']?.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?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
|
||||||
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
|
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
|
||||||
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
|
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
|
||||||
@@ -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://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
|
||||||
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
|
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
|
||||||
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
|
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
|
||||||
check(indexSource.includes('Version: v1.0.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(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
|
||||||
check(fs.existsSync(manifestPath), 'public release manifest is missing');
|
check(fs.existsSync(manifestPath), 'public release manifest is missing');
|
||||||
|
|
||||||
|
|||||||
@@ -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.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)}`);
|
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 mergeAddToolbarActions = await win.evaluate(() => {
|
||||||
const capture = () => {
|
const capture = () => {
|
||||||
const button = document.querySelector('[data-toolbar-for="merge"] button[onclick="addMergeFiles()"]');
|
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.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)}`);
|
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 = [];
|
const diagnosticLayouts = [];
|
||||||
for (const target of [TARGETS[2], TARGETS[1], TARGETS[0]]) {
|
for (const target of [TARGETS[2], TARGETS[1], TARGETS[0]]) {
|
||||||
await win.setViewportSize(target);
|
await win.setViewportSize(target);
|
||||||
@@ -1483,17 +1677,17 @@ async function run() {
|
|||||||
await win.emulateMedia({ colorScheme: 'dark' });
|
await win.emulateMedia({ colorScheme: 'dark' });
|
||||||
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
|
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
|
||||||
await win.waitForTimeout(260);
|
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.locator('#workspaceThemePicker [data-theme="system"]').click();
|
||||||
await win.waitForTimeout(260);
|
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.locator('#workspaceThemePicker [data-theme="light"]').click();
|
||||||
await win.waitForTimeout(260);
|
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.emulateMedia({ colorScheme: 'light' });
|
||||||
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
|
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
|
||||||
await win.waitForTimeout(260);
|
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 };
|
checks.themes = { darkTheme, systemDarkTheme, lightTheme, systemLightTheme };
|
||||||
|
|
||||||
for (const [name, theme] of Object.entries({ dark: darkTheme, systemDark: systemDarkTheme, light: lightTheme, systemLight: 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(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(lightTheme.bodyClass === 'theme-light', `Light theme body class is ${lightTheme.bodyClass}`);
|
||||||
check(systemLightTheme.bodyClass === 'theme-system', `System theme body class is ${systemLightTheme.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([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([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');
|
check(darkTheme.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background');
|
||||||
|
|||||||
@@ -6,14 +6,23 @@ const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8');
|
|||||||
const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8');
|
const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8');
|
||||||
|
|
||||||
describe('cutter workspace style production paths', () => {
|
describe('cutter workspace style production paths', () => {
|
||||||
test('hides the source bar when the non-adjacent workspace is shown', () => {
|
test('keeps loaded-source visibility independent from the large-window media query', () => {
|
||||||
expect(styles).toContain('.cutter-source-bar:has(~ .cutter-workspace.shown)');
|
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 selector = '#cutterTab .cutter-export-options select {';
|
||||||
const start = workspaceStyles.indexOf(selector);
|
const start = workspaceStyles.indexOf(selector);
|
||||||
const end = workspaceStyles.indexOf('}', start);
|
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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -942,7 +942,7 @@
|
|||||||
|
|
||||||
<div class="settings-card" data-settings-pane="updates" hidden>
|
<div class="settings-card" data-settings-pane="updates" hidden>
|
||||||
<h3 id="updateTitle">Updates</h3>
|
<h3 id="updateTitle">Updates</h3>
|
||||||
<p id="versionInfo" class="card-intro">Version: v1.0.16</p>
|
<p id="versionInfo" class="card-intro">Version: v1.0.17</p>
|
||||||
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -7081,8 +7081,13 @@ async function processQueue(manualOverride = false): Promise<void> {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// WINDOW CREATION
|
// WINDOW CREATION
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
function resolveNativeThemeSource(theme: string): 'system' | 'light' | 'dark' {
|
||||||
|
if (theme === 'system') return 'system';
|
||||||
|
return theme === 'light' ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
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');
|
const windowIconPath = WINDOWS_APP_ICON_PATH ?? path.join(__dirname, '../build/icon.png');
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -7571,7 +7576,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.theme !== previousTheme) {
|
if (config.theme !== previousTheme) {
|
||||||
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
nativeTheme.themeSource = resolveNativeThemeSource(config.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.persist_queue_on_restart === false) {
|
if (config.persist_queue_on_restart === false) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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<void>; resolve: () => void } {
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||||
let resolve!: () => void;
|
let resolve!: () => void;
|
||||||
@@ -20,6 +22,82 @@ function createResource(wait: Promise<void> = 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', () => {
|
describe('QueueProcessRegistry', () => {
|
||||||
it('keeps parallel queue item process groups independent', async () => {
|
it('keeps parallel queue item process groups independent', async () => {
|
||||||
const registry = new QueueProcessRegistry();
|
const registry = new QueueProcessRegistry();
|
||||||
|
|||||||
@@ -5,15 +5,27 @@ export type QueueProcessPhase = 'streamlink' | 'merge' | 'split' | 'post-process
|
|||||||
export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000): Promise<void> {
|
export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000): Promise<void> {
|
||||||
if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve();
|
if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let settled = false;
|
||||||
const finish = (): void => {
|
const finish = (): void => {
|
||||||
clearTimeout(timer);
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (forceKillTimer) clearTimeout(forceKillTimer);
|
||||||
|
if (settleTimer) clearTimeout(settleTimer);
|
||||||
|
process.removeListener('close', finish);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => {
|
process.once('close', finish);
|
||||||
if (process.exitCode !== null || process.signalCode !== null) return;
|
forceKillTimer = setTimeout(() => {
|
||||||
|
forceKillTimer = null;
|
||||||
|
if (process.exitCode !== null || process.signalCode !== null) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settleTimer = setTimeout(finish, forceKillAfterMs);
|
||||||
try { process.kill('SIGKILL'); } catch { }
|
try { process.kill('SIGKILL'); } catch { }
|
||||||
}, forceKillAfterMs);
|
}, forceKillAfterMs);
|
||||||
process.once('close', finish);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ function sourceFragment(start: string, end: string): string {
|
|||||||
return source.slice(from, to);
|
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<string, unknown>, expose: string): Record<string, (...args: unknown[]) => unknown> {
|
function evaluate(source: string, context: Record<string, unknown>, expose: string): Record<string, (...args: unknown[]) => unknown> {
|
||||||
context.globalThis = context;
|
context.globalThis = context;
|
||||||
context.window = context;
|
context.window = context;
|
||||||
@@ -22,7 +34,68 @@ function evaluate(source: string, context: Record<string, unknown>, expose: stri
|
|||||||
return (context as { __cutterProductionPath: Record<string, (...args: unknown[]) => unknown> }).__cutterProductionPath;
|
return (context as { __cutterProductionPath: Record<string, (...args: unknown[]) => 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<string, FakeSelect> {
|
||||||
|
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', () => {
|
describe('cutter production paths', () => {
|
||||||
|
test('rejects a PNG drop before requesting a capability or loader', async () => {
|
||||||
|
const listeners = new Map<string, (event: Record<string, unknown>) => Promise<void> | void>();
|
||||||
|
let capabilityRequests = 0;
|
||||||
|
let loadRequests = 0;
|
||||||
|
const toasts: Array<[string, string]> = [];
|
||||||
|
const tab = {
|
||||||
|
addEventListener: (name: string, listener: (event: Record<string, unknown>) => Promise<void> | 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 () => {
|
test('opens a saved project without first overwriting its autosave', async () => {
|
||||||
let saves = 0;
|
let saves = 0;
|
||||||
let opens = 0;
|
let opens = 0;
|
||||||
@@ -69,6 +142,86 @@ describe('cutter production paths', () => {
|
|||||||
expect(saves).toBe(0);
|
expect(saves).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps a recovered hardware encoder while export options are still loading', () => {
|
||||||
|
const selects = createCutterSelects();
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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 () => {
|
test('offers recovery before enabling edits or starting the encoder probe', async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const elements = new Map<string, Record<string, unknown>>();
|
const elements = new Map<string, Record<string, unknown>>();
|
||||||
@@ -150,4 +303,36 @@ describe('cutter production paths', () => {
|
|||||||
expect(events.indexOf('recovery')).toBeLessThan(events.indexOf('enable'));
|
expect(events.indexOf('recovery')).toBeLessThan(events.indexOf('enable'));
|
||||||
expect(events.indexOf('offer')).toBeLessThan(events.indexOf('probe'));
|
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']]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-8
@@ -66,7 +66,7 @@ let cutterExportEncoder: 'software' | 'h264_nvenc' | 'h264_qsv' | 'h264_amf' = '
|
|||||||
let cutterAudioStreamIndex = 0;
|
let cutterAudioStreamIndex = 0;
|
||||||
let cutterPendingProject: CutterProject | null = null;
|
let cutterPendingProject: CutterProject | null = null;
|
||||||
let cutterAutosaveTimer: number | null = null;
|
let cutterAutosaveTimer: number | null = null;
|
||||||
let cutterExportOptions: CutterExportOptions | null = null;
|
let cutterExportOptions: CutterExportOptions | null | undefined;
|
||||||
let cutterRecoveryDecisionPending = false;
|
let cutterRecoveryDecisionPending = false;
|
||||||
const cutterMaximumCuts = 64;
|
const cutterMaximumCuts = 64;
|
||||||
const cutterFrameTolerance = 1e-8;
|
const cutterFrameTolerance = 1e-8;
|
||||||
@@ -212,7 +212,7 @@ function updateCutterAudioStreams(): void {
|
|||||||
select.disabled = false;
|
select.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCutterExportControls(options: CutterExportOptions | null): void {
|
function updateCutterExportControls(options: CutterExportOptions | null | undefined): void {
|
||||||
const profile = byId<HTMLSelectElement>('cutterExportProfile');
|
const profile = byId<HTMLSelectElement>('cutterExportProfile');
|
||||||
const encoder = byId<HTMLSelectElement>('cutterExportEncoder');
|
const encoder = byId<HTMLSelectElement>('cutterExportEncoder');
|
||||||
if (options) {
|
if (options) {
|
||||||
@@ -230,16 +230,18 @@ function updateCutterExportControls(options: CutterExportOptions | null): void {
|
|||||||
software.textContent = 'Software';
|
software.textContent = 'Software';
|
||||||
encoder.append(software);
|
encoder.append(software);
|
||||||
if (cutterExportProfile !== 'archive') {
|
if (cutterExportProfile !== 'archive') {
|
||||||
(options?.hardwareEncoders ?? []).forEach((value) => {
|
const hardwareEncoders = options?.hardwareEncoders
|
||||||
|
?? (options === undefined && cutterExportEncoder !== 'software' ? [cutterExportEncoder] : []);
|
||||||
|
hardwareEncoders.forEach((value) => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = value;
|
option.value = value;
|
||||||
option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF';
|
option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF';
|
||||||
encoder.append(option);
|
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.value = cutterExportEncoder;
|
||||||
encoder.disabled = cutterExportProfile === 'archive';
|
encoder.disabled = !options || cutterExportProfile === 'archive';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise<void> {
|
async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise<void> {
|
||||||
@@ -914,7 +916,7 @@ function setCutterControlsEnabled(enabled: boolean): void {
|
|||||||
byId<HTMLButtonElement>('cutterSaveProjectBtn').disabled = !enabled;
|
byId<HTMLButtonElement>('cutterSaveProjectBtn').disabled = !enabled;
|
||||||
byId<HTMLButtonElement>('cutterOpenProjectBtn').disabled = !enabled;
|
byId<HTMLButtonElement>('cutterOpenProjectBtn').disabled = !enabled;
|
||||||
byId<HTMLSelectElement>('cutterExportProfile').disabled = !enabled;
|
byId<HTMLSelectElement>('cutterExportProfile').disabled = !enabled;
|
||||||
byId<HTMLSelectElement>('cutterExportEncoder').disabled = !enabled || cutterExportProfile === 'archive';
|
byId<HTMLSelectElement>('cutterExportEncoder').disabled = !enabled || !cutterExportOptions || cutterExportProfile === 'archive';
|
||||||
byId<HTMLSelectElement>('cutterAudioStream').disabled = !enabled || (cutterVideoInfo?.audioStreams.length ?? 0) === 0;
|
byId<HTMLSelectElement>('cutterAudioStream').disabled = !enabled || (cutterVideoInfo?.audioStreams.length ?? 0) === 0;
|
||||||
const volumeControl = document.querySelector<HTMLElement>('.cutter-volume-control');
|
const volumeControl = document.querySelector<HTMLElement>('.cutter-volume-control');
|
||||||
volumeControl?.classList.toggle('disabled', !enabled);
|
volumeControl?.classList.toggle('disabled', !enabled);
|
||||||
@@ -1072,6 +1074,7 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
cutterActiveCutId = null;
|
cutterActiveCutId = null;
|
||||||
cutterExportProfile = 'balanced';
|
cutterExportProfile = 'balanced';
|
||||||
cutterExportEncoder = 'software';
|
cutterExportEncoder = 'software';
|
||||||
|
cutterExportOptions = undefined;
|
||||||
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
|
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
|
||||||
cutterRecoveryDecisionPending = true;
|
cutterRecoveryDecisionPending = true;
|
||||||
renderCutterProjectRecovery(null);
|
renderCutterProjectRecovery(null);
|
||||||
@@ -1160,8 +1163,19 @@ async function requestCutterVideoReplacement(file: FileCapabilityReference): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectCutterVideo(): Promise<void> {
|
async function selectCutterVideo(): Promise<void> {
|
||||||
const file = await window.api.selectVideoFile();
|
let file: FileCapabilityReference | null;
|
||||||
if (file) await requestCutterVideoReplacement(file);
|
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 {
|
function updateTimeFromInput(): void {
|
||||||
|
|||||||
+41
-14
@@ -85,7 +85,7 @@ const UI_TEXT_DE = {
|
|||||||
parallelDownloads1: '1 (Standard)',
|
parallelDownloads1: '1 (Standard)',
|
||||||
parallelDownloads2: '2 (Parallel)',
|
parallelDownloads2: '2 (Parallel)',
|
||||||
performanceModeLabel: 'Performance-Profil',
|
performanceModeLabel: 'Performance-Profil',
|
||||||
performanceModeStability: 'Max Stabilitat',
|
performanceModeStability: 'Max Stabilität',
|
||||||
performanceModeBalanced: 'Ausgewogen',
|
performanceModeBalanced: 'Ausgewogen',
|
||||||
performanceModeSpeed: 'Max Geschwindigkeit',
|
performanceModeSpeed: 'Max Geschwindigkeit',
|
||||||
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
||||||
@@ -264,24 +264,51 @@ const UI_TEXT_DE = {
|
|||||||
templateGuideContextParts: 'Kontext: Beispiel für VOD-Teil',
|
templateGuideContextParts: 'Kontext: Beispiel für VOD-Teil',
|
||||||
templateGuideContextClip: 'Kontext: Beispiel für Clip-Zuschnitt',
|
templateGuideContextClip: 'Kontext: Beispiel für Clip-Zuschnitt',
|
||||||
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
|
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
|
||||||
runtimeMetricsTitle: 'Runtime Metrics',
|
runtimeMetricsTitle: 'Laufzeitmetriken',
|
||||||
runtimeMetricsRefresh: 'Aktualisieren',
|
runtimeMetricsRefresh: 'Aktualisieren',
|
||||||
runtimeMetricsExport: 'Export JSON',
|
runtimeMetricsExport: 'JSON exportieren',
|
||||||
runtimeMetricsAutoRefresh: 'Auto-Refresh',
|
runtimeMetricsAutoRefresh: 'Automatisch aktualisieren',
|
||||||
runtimeMetricsLoading: 'Metriken werden geladen...',
|
runtimeMetricsLoading: 'Laufzeitmetriken werden geladen...',
|
||||||
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
|
runtimeMetricsError: 'Laufzeitmetriken konnten nicht geladen werden.',
|
||||||
runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.',
|
runtimeMetricsExportDone: 'Laufzeitmetriken wurden als JSON exportiert.',
|
||||||
runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.',
|
runtimeMetricsExportCancelled: 'Export der Laufzeitmetriken abgebrochen.',
|
||||||
runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.',
|
runtimeMetricsExportFailed: 'Export der Laufzeitmetriken fehlgeschlagen.',
|
||||||
runtimeMetricQueue: 'Queue',
|
runtimeMetricQueue: 'Warteschlange',
|
||||||
|
runtimeMetricQueueSummary: '{total} insgesamt ({pending} ausstehend, {downloading} laufend, {failed} fehlgeschlagen)',
|
||||||
runtimeMetricMode: 'Modus',
|
runtimeMetricMode: 'Modus',
|
||||||
runtimeMetricRetries: 'Retries',
|
runtimeMetricModeSummary: '{mode} | Intelligente Planung: {smartScheduler} | Duplikatschutz: {duplicatePrevention}',
|
||||||
runtimeMetricIntegrity: 'Integritatsfehler',
|
runtimeMetricEnabled: 'aktiviert',
|
||||||
runtimeMetricCache: 'Cache',
|
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',
|
runtimeMetricBandwidth: 'Bandbreite',
|
||||||
|
runtimeMetricBandwidthSummary: 'aktuell {current}/s, durchschnittlich {average}/s',
|
||||||
runtimeMetricDownloads: 'Downloads',
|
runtimeMetricDownloads: 'Downloads',
|
||||||
runtimeMetricActive: 'Aktiver Job',
|
runtimeMetricDownloadsSummary: '{started} gestartet, {completed} abgeschlossen, {failed} fehlgeschlagen, {bytes} übertragen',
|
||||||
|
runtimeMetricActive: 'Aktiver Eintrag',
|
||||||
runtimeMetricLastError: 'Letzte Fehlerklasse',
|
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',
|
runtimeMetricUpdated: 'Aktualisiert',
|
||||||
updateTitle: 'Updates',
|
updateTitle: 'Updates',
|
||||||
checkUpdates: 'Nach Updates suchen',
|
checkUpdates: 'Nach Updates suchen',
|
||||||
|
|||||||
@@ -274,14 +274,41 @@ const UI_TEXT_EN = {
|
|||||||
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
|
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
|
||||||
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
|
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
|
||||||
runtimeMetricQueue: 'Queue',
|
runtimeMetricQueue: 'Queue',
|
||||||
|
runtimeMetricQueueSummary: '{total} total ({pending} pending, {downloading} downloading, {failed} failed)',
|
||||||
runtimeMetricMode: 'Mode',
|
runtimeMetricMode: 'Mode',
|
||||||
|
runtimeMetricModeSummary: '{mode} | Smart scheduler: {smartScheduler} | Duplicate prevention: {duplicatePrevention}',
|
||||||
|
runtimeMetricEnabled: 'enabled',
|
||||||
|
runtimeMetricDisabled: 'disabled',
|
||||||
runtimeMetricRetries: 'Retries',
|
runtimeMetricRetries: 'Retries',
|
||||||
|
runtimeMetricRetriesSummary: '{scheduled} scheduled, {exhausted} exhausted',
|
||||||
runtimeMetricIntegrity: 'Integrity failures',
|
runtimeMetricIntegrity: 'Integrity failures',
|
||||||
runtimeMetricCache: 'Cache',
|
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',
|
runtimeMetricBandwidth: 'Bandwidth',
|
||||||
|
runtimeMetricBandwidthSummary: 'current {current}/s, average {average}/s',
|
||||||
runtimeMetricDownloads: 'Downloads',
|
runtimeMetricDownloads: 'Downloads',
|
||||||
|
runtimeMetricDownloadsSummary: '{started} started, {completed} completed, {failed} failed, {bytes} transferred',
|
||||||
runtimeMetricActive: 'Active item',
|
runtimeMetricActive: 'Active item',
|
||||||
runtimeMetricLastError: 'Last error class',
|
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',
|
runtimeMetricUpdated: 'Updated',
|
||||||
updateTitle: 'Updates',
|
updateTitle: 'Updates',
|
||||||
checkUpdates: 'Check for updates',
|
checkUpdates: 'Check for updates',
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ function createInput(value = '', checked = false): Input {
|
|||||||
return { value, checked };
|
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', () => {
|
describe('renderer settings autosave orchestration', () => {
|
||||||
it('persists a pure download policy change through the real autosave fingerprint', async () => {
|
it('persists a pure download policy change through the real autosave fingerprint', async () => {
|
||||||
const inputs = new Map(inputIds.map((id) => [id, createInput()]));
|
const inputs = new Map(inputIds.map((id) => [id, createInput()]));
|
||||||
@@ -128,3 +141,25 @@ describe('renderer settings autosave orchestration', () => {
|
|||||||
expect(saveConfigCalls).toHaveLength(2);
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,6 +55,45 @@ function formatBytesForMetrics(bytes: number): string {
|
|||||||
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRuntimeMetricSummary(template: string, values: Record<string, string | number>): 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<RuntimeMetricsSnapshot['config']['performanceMode'], string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 {
|
function validateFilenameTemplates(showAlert = false): boolean {
|
||||||
const templates = [
|
const templates = [
|
||||||
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
||||||
@@ -123,15 +162,44 @@ async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const metrics = await window.api.getRuntimeMetrics();
|
const metrics = await window.api.getRuntimeMetrics();
|
||||||
const lines = [
|
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.runtimeMetricQueue}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricQueueSummary, {
|
||||||
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
|
total: metrics.queue.total,
|
||||||
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
|
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.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.runtimeMetricCache}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricCacheSummary, {
|
||||||
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
|
hits: formatRuntimeMetricCount(metrics.cacheHits, UI_TEXT.static.runtimeMetricCacheHitOne, UI_TEXT.static.runtimeMetricCacheHitMany),
|
||||||
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
|
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.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')}`
|
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -591,12 +659,12 @@ async function importConfigFromFile(): Promise<void> {
|
|||||||
const result = await window.api.importConfig();
|
const result = await window.api.importConfig();
|
||||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
invalidatePreflightResult();
|
||||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||||
try {
|
try {
|
||||||
config = await window.api.getConfig();
|
config = await window.api.getConfig();
|
||||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||||
setLanguage(config.language);
|
setLanguage(config.language);
|
||||||
invalidatePreflightResult();
|
|
||||||
}
|
}
|
||||||
if (typeof renderStreamers === 'function') renderStreamers();
|
if (typeof renderStreamers === 'function') renderStreamers();
|
||||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||||
@@ -604,6 +672,7 @@ async function importConfigFromFile(): Promise<void> {
|
|||||||
renderVodGridFromCurrentState();
|
renderVodGridFromCurrentState();
|
||||||
}
|
}
|
||||||
} catch { /* ignore — next refresh will catch up */ }
|
} catch { /* ignore — next refresh will catch up */ }
|
||||||
|
refreshLocalizedPreflightUi();
|
||||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||||
} else if (result.cancelled) {
|
} else if (result.cancelled) {
|
||||||
// User cancelled the dialog — no toast needed.
|
// User cancelled the dialog — no toast needed.
|
||||||
|
|||||||
@@ -452,6 +452,10 @@ function initVodScrollTracking(): void {
|
|||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedCutterVideoFile(file: { name: string }): boolean {
|
||||||
|
return /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
function initCutterDragDrop(): void {
|
function initCutterDragDrop(): void {
|
||||||
const tab = document.getElementById('cutterTab');
|
const tab = document.getElementById('cutterTab');
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
@@ -485,8 +489,7 @@ function initCutterDragDrop(): void {
|
|||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files || []);
|
const files = Array.from(e.dataTransfer.files || []);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
const allowed = /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i;
|
const file = files.find(isSupportedCutterVideoFile);
|
||||||
const file = files.find((entry) => allowed.test(entry.name));
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
|
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
|
||||||
return;
|
return;
|
||||||
|
|||||||
+7
-3
@@ -3086,6 +3086,10 @@ input[type="checkbox"].vod-select-checkbox {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1181px) and (min-height: 680px) {
|
@media (min-width: 1181px) and (min-height: 680px) {
|
||||||
#cutterTab {
|
#cutterTab {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
@@ -3101,8 +3105,8 @@ input[type="checkbox"].vod-select-checkbox {
|
|||||||
grid-template-rows: minmax(0, 1fr) auto;
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) {
|
#cutterTab .cutter-container:has(.cutter-workspace.shown):has(> .cutter-recovery-panel:not([hidden])) {
|
||||||
display: none;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cutterTab .cutter-workspace {
|
#cutterTab .cutter-workspace {
|
||||||
@@ -3214,7 +3218,7 @@ input[type="checkbox"].vod-select-checkbox {
|
|||||||
|
|
||||||
.cutter-workspace {
|
.cutter-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user