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:
@@ -6,14 +6,23 @@ const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8');
|
||||
const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8');
|
||||
|
||||
describe('cutter workspace style production paths', () => {
|
||||
test('hides the source bar when the non-adjacent workspace is shown', () => {
|
||||
expect(styles).toContain('.cutter-source-bar:has(~ .cutter-workspace.shown)');
|
||||
test('keeps loaded-source visibility independent from the large-window media query', () => {
|
||||
const selector = '#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown)';
|
||||
const selectorIndex = styles.indexOf(selector);
|
||||
const mediaStart = styles.indexOf('@media (min-width: 1181px) and (min-height: 680px)');
|
||||
const nextTopLevelRule = styles.indexOf('\n.cutter-source-bar {', mediaStart);
|
||||
expect(selectorIndex).toBeGreaterThan(-1);
|
||||
expect(selectorIndex < mediaStart || selectorIndex > nextTopLevelRule).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the export profile indicator from tiling after workspace background styling', () => {
|
||||
test('keeps one reserved non-repeating indicator on every cutter export select', () => {
|
||||
const selector = '#cutterTab .cutter-export-options select {';
|
||||
const start = workspaceStyles.indexOf(selector);
|
||||
const end = workspaceStyles.indexOf('}', start);
|
||||
expect(workspaceStyles.slice(start, end)).toMatch(/background-repeat:\s*no-repeat/);
|
||||
const rule = workspaceStyles.slice(start, end);
|
||||
expect(rule.match(/background-image:/g)).toHaveLength(1);
|
||||
expect(rule).toMatch(/appearance:\s*none/);
|
||||
expect(rule).toMatch(/padding-right:\s*28px/);
|
||||
expect(rule).toMatch(/background-repeat:\s*no-repeat/);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -942,7 +942,7 @@
|
||||
|
||||
<div class="settings-card" data-settings-pane="updates" hidden>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
+7
-2
@@ -7081,8 +7081,13 @@ async function processQueue(manualOverride = false): Promise<void> {
|
||||
// ==========================================
|
||||
// WINDOW CREATION
|
||||
// ==========================================
|
||||
function resolveNativeThemeSource(theme: string): 'system' | 'light' | 'dark' {
|
||||
if (theme === 'system') return 'system';
|
||||
return theme === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
||||
nativeTheme.themeSource = resolveNativeThemeSource(config.theme);
|
||||
const windowIconPath = WINDOWS_APP_ICON_PATH ?? path.join(__dirname, '../build/icon.png');
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -7571,7 +7576,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
||||
}
|
||||
|
||||
if (config.theme !== previousTheme) {
|
||||
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
||||
nativeTheme.themeSource = resolveNativeThemeSource(config.theme);
|
||||
}
|
||||
|
||||
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 { QueueProcessRegistry, QueueRunLifecycle, type QueueProcessResource } from './process-registry';
|
||||
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit, type QueueProcessResource } from './process-registry';
|
||||
|
||||
function deferred(): { promise: Promise<void>; 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', () => {
|
||||
it('keeps parallel queue item process groups independent', async () => {
|
||||
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> {
|
||||
if (!process || process.exitCode !== null || process.signalCode !== null) return 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 => {
|
||||
clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (forceKillTimer) clearTimeout(forceKillTimer);
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
process.removeListener('close', finish);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
if (process.exitCode !== null || process.signalCode !== null) return;
|
||||
process.once('close', finish);
|
||||
forceKillTimer = setTimeout(() => {
|
||||
forceKillTimer = null;
|
||||
if (process.exitCode !== null || process.signalCode !== null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
settleTimer = setTimeout(finish, forceKillAfterMs);
|
||||
try { process.kill('SIGKILL'); } catch { }
|
||||
}, forceKillAfterMs);
|
||||
process.once('close', finish);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,18 @@ function sourceFragment(start: string, end: string): string {
|
||||
return source.slice(from, to);
|
||||
}
|
||||
|
||||
function streamerSourceFragment(start: string, end: string): string {
|
||||
const source = readFileSync(join(__dirname, 'renderer-streamers.ts'), 'utf8');
|
||||
const from = source.indexOf(start);
|
||||
const to = source.indexOf(end, from);
|
||||
if (from < 0 || to < 0) throw new Error('Missing renderer streamers production fragment');
|
||||
return source.slice(from, to);
|
||||
}
|
||||
|
||||
function cutterFileValidationFragment(): string {
|
||||
return streamerSourceFragment('function isSupportedCutterVideoFile', 'function initCutterDragDrop');
|
||||
}
|
||||
|
||||
function evaluate(source: string, context: Record<string, unknown>, expose: string): Record<string, (...args: unknown[]) => unknown> {
|
||||
context.globalThis = 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;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
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 () => {
|
||||
let saves = 0;
|
||||
let opens = 0;
|
||||
@@ -69,6 +142,86 @@ describe('cutter production paths', () => {
|
||||
expect(saves).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps a recovered hardware encoder while export options are still loading', () => {
|
||||
const selects = createCutterSelects();
|
||||
const context: Record<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 () => {
|
||||
const events: string[] = [];
|
||||
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('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 cutterPendingProject: CutterProject | null = null;
|
||||
let cutterAutosaveTimer: number | null = null;
|
||||
let cutterExportOptions: CutterExportOptions | null = null;
|
||||
let cutterExportOptions: CutterExportOptions | null | undefined;
|
||||
let cutterRecoveryDecisionPending = false;
|
||||
const cutterMaximumCuts = 64;
|
||||
const cutterFrameTolerance = 1e-8;
|
||||
@@ -212,7 +212,7 @@ function updateCutterAudioStreams(): void {
|
||||
select.disabled = false;
|
||||
}
|
||||
|
||||
function updateCutterExportControls(options: CutterExportOptions | null): void {
|
||||
function updateCutterExportControls(options: CutterExportOptions | null | undefined): void {
|
||||
const profile = byId<HTMLSelectElement>('cutterExportProfile');
|
||||
const encoder = byId<HTMLSelectElement>('cutterExportEncoder');
|
||||
if (options) {
|
||||
@@ -230,16 +230,18 @@ function updateCutterExportControls(options: CutterExportOptions | null): void {
|
||||
software.textContent = 'Software';
|
||||
encoder.append(software);
|
||||
if (cutterExportProfile !== 'archive') {
|
||||
(options?.hardwareEncoders ?? []).forEach((value) => {
|
||||
const hardwareEncoders = options?.hardwareEncoders
|
||||
?? (options === undefined && cutterExportEncoder !== 'software' ? [cutterExportEncoder] : []);
|
||||
hardwareEncoders.forEach((value) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF';
|
||||
encoder.append(option);
|
||||
});
|
||||
}
|
||||
if (!Array.from(encoder.options).some((option) => option.value === cutterExportEncoder)) cutterExportEncoder = 'software';
|
||||
if (cutterExportProfile === 'archive' || (options !== undefined && !Array.from(encoder.options).some((option) => option.value === cutterExportEncoder))) cutterExportEncoder = 'software';
|
||||
encoder.value = cutterExportEncoder;
|
||||
encoder.disabled = cutterExportProfile === 'archive';
|
||||
encoder.disabled = !options || cutterExportProfile === 'archive';
|
||||
}
|
||||
|
||||
async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise<void> {
|
||||
@@ -914,7 +916,7 @@ function setCutterControlsEnabled(enabled: boolean): void {
|
||||
byId<HTMLButtonElement>('cutterSaveProjectBtn').disabled = !enabled;
|
||||
byId<HTMLButtonElement>('cutterOpenProjectBtn').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;
|
||||
const volumeControl = document.querySelector<HTMLElement>('.cutter-volume-control');
|
||||
volumeControl?.classList.toggle('disabled', !enabled);
|
||||
@@ -1072,6 +1074,7 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
||||
cutterActiveCutId = null;
|
||||
cutterExportProfile = 'balanced';
|
||||
cutterExportEncoder = 'software';
|
||||
cutterExportOptions = undefined;
|
||||
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
|
||||
cutterRecoveryDecisionPending = true;
|
||||
renderCutterProjectRecovery(null);
|
||||
@@ -1160,8 +1163,19 @@ async function requestCutterVideoReplacement(file: FileCapabilityReference): Pro
|
||||
}
|
||||
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
const file = await window.api.selectVideoFile();
|
||||
if (file) await requestCutterVideoReplacement(file);
|
||||
let file: FileCapabilityReference | null;
|
||||
try {
|
||||
file = await window.api.selectVideoFile();
|
||||
} catch {
|
||||
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
if (!isSupportedCutterVideoFile(file)) {
|
||||
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
|
||||
return;
|
||||
}
|
||||
await requestCutterVideoReplacement(file);
|
||||
}
|
||||
|
||||
function updateTimeFromInput(): void {
|
||||
|
||||
+41
-14
@@ -85,7 +85,7 @@ const UI_TEXT_DE = {
|
||||
parallelDownloads1: '1 (Standard)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance-Profil',
|
||||
performanceModeStability: 'Max Stabilitat',
|
||||
performanceModeStability: 'Max Stabilität',
|
||||
performanceModeBalanced: 'Ausgewogen',
|
||||
performanceModeSpeed: 'Max Geschwindigkeit',
|
||||
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
||||
@@ -264,24 +264,51 @@ const UI_TEXT_DE = {
|
||||
templateGuideContextParts: 'Kontext: Beispiel für VOD-Teil',
|
||||
templateGuideContextClip: 'Kontext: Beispiel für Clip-Zuschnitt',
|
||||
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsTitle: 'Laufzeitmetriken',
|
||||
runtimeMetricsRefresh: 'Aktualisieren',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto-Refresh',
|
||||
runtimeMetricsLoading: 'Metriken werden geladen...',
|
||||
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
|
||||
runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.',
|
||||
runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.',
|
||||
runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricsExport: 'JSON exportieren',
|
||||
runtimeMetricsAutoRefresh: 'Automatisch aktualisieren',
|
||||
runtimeMetricsLoading: 'Laufzeitmetriken werden geladen...',
|
||||
runtimeMetricsError: 'Laufzeitmetriken konnten nicht geladen werden.',
|
||||
runtimeMetricsExportDone: 'Laufzeitmetriken wurden als JSON exportiert.',
|
||||
runtimeMetricsExportCancelled: 'Export der Laufzeitmetriken abgebrochen.',
|
||||
runtimeMetricsExportFailed: 'Export der Laufzeitmetriken fehlgeschlagen.',
|
||||
runtimeMetricQueue: 'Warteschlange',
|
||||
runtimeMetricQueueSummary: '{total} insgesamt ({pending} ausstehend, {downloading} laufend, {failed} fehlgeschlagen)',
|
||||
runtimeMetricMode: 'Modus',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
runtimeMetricIntegrity: 'Integritatsfehler',
|
||||
runtimeMetricCache: 'Cache',
|
||||
runtimeMetricModeSummary: '{mode} | Intelligente Planung: {smartScheduler} | Duplikatschutz: {duplicatePrevention}',
|
||||
runtimeMetricEnabled: 'aktiviert',
|
||||
runtimeMetricDisabled: 'deaktiviert',
|
||||
runtimeMetricRetries: 'Wiederholungen',
|
||||
runtimeMetricRetriesSummary: '{scheduled} geplant, {exhausted} ausgeschöpft',
|
||||
runtimeMetricIntegrity: 'Integritätsfehler',
|
||||
runtimeMetricCache: 'Zwischenspeicher',
|
||||
runtimeMetricCacheSummary: '{hits}, {misses}, {vods}, {users}, {clips}',
|
||||
runtimeMetricCacheHitOne: '{count} Treffer',
|
||||
runtimeMetricCacheHitMany: '{count} Treffer',
|
||||
runtimeMetricCacheMissOne: '{count} Fehlzugriff',
|
||||
runtimeMetricCacheMissMany: '{count} Fehlzugriffe',
|
||||
runtimeMetricCacheVodOne: '{count} VOD',
|
||||
runtimeMetricCacheVodMany: '{count} VODs',
|
||||
runtimeMetricCacheUserOne: '{count} Nutzer',
|
||||
runtimeMetricCacheUserMany: '{count} Nutzer',
|
||||
runtimeMetricCacheClipOne: '{count} Clip',
|
||||
runtimeMetricCacheClipMany: '{count} Clips',
|
||||
runtimeMetricBandwidth: 'Bandbreite',
|
||||
runtimeMetricBandwidthSummary: 'aktuell {current}/s, durchschnittlich {average}/s',
|
||||
runtimeMetricDownloads: 'Downloads',
|
||||
runtimeMetricActive: 'Aktiver Job',
|
||||
runtimeMetricDownloadsSummary: '{started} gestartet, {completed} abgeschlossen, {failed} fehlgeschlagen, {bytes} übertragen',
|
||||
runtimeMetricActive: 'Aktiver Eintrag',
|
||||
runtimeMetricLastError: 'Letzte Fehlerklasse',
|
||||
runtimeMetricLastErrorSummary: '{errorClass}, Wiederholungsverzögerung: {retryDelay} s',
|
||||
runtimeMetricErrorNetwork: 'Netzwerk',
|
||||
runtimeMetricErrorRateLimit: 'Anfragelimit',
|
||||
runtimeMetricErrorAuth: 'Authentifizierung',
|
||||
runtimeMetricErrorTooling: 'Externe Tools',
|
||||
runtimeMetricErrorIntegrity: 'Integrität',
|
||||
runtimeMetricErrorIo: 'Dateisystem',
|
||||
runtimeMetricErrorValidation: 'Validierung',
|
||||
runtimeMetricErrorUnknown: 'Unbekannt',
|
||||
runtimeMetricUpdated: 'Aktualisiert',
|
||||
updateTitle: 'Updates',
|
||||
checkUpdates: 'Nach Updates suchen',
|
||||
|
||||
@@ -274,14 +274,41 @@ const UI_TEXT_EN = {
|
||||
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
|
||||
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricQueueSummary: '{total} total ({pending} pending, {downloading} downloading, {failed} failed)',
|
||||
runtimeMetricMode: 'Mode',
|
||||
runtimeMetricModeSummary: '{mode} | Smart scheduler: {smartScheduler} | Duplicate prevention: {duplicatePrevention}',
|
||||
runtimeMetricEnabled: 'enabled',
|
||||
runtimeMetricDisabled: 'disabled',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
runtimeMetricRetriesSummary: '{scheduled} scheduled, {exhausted} exhausted',
|
||||
runtimeMetricIntegrity: 'Integrity failures',
|
||||
runtimeMetricCache: 'Cache',
|
||||
runtimeMetricCacheSummary: '{hits}, {misses}, {vods}, {users}, {clips}',
|
||||
runtimeMetricCacheHitOne: '{count} hit',
|
||||
runtimeMetricCacheHitMany: '{count} hits',
|
||||
runtimeMetricCacheMissOne: '{count} miss',
|
||||
runtimeMetricCacheMissMany: '{count} misses',
|
||||
runtimeMetricCacheVodOne: '{count} VOD',
|
||||
runtimeMetricCacheVodMany: '{count} VODs',
|
||||
runtimeMetricCacheUserOne: '{count} user',
|
||||
runtimeMetricCacheUserMany: '{count} users',
|
||||
runtimeMetricCacheClipOne: '{count} clip',
|
||||
runtimeMetricCacheClipMany: '{count} clips',
|
||||
runtimeMetricBandwidth: 'Bandwidth',
|
||||
runtimeMetricBandwidthSummary: 'current {current}/s, average {average}/s',
|
||||
runtimeMetricDownloads: 'Downloads',
|
||||
runtimeMetricDownloadsSummary: '{started} started, {completed} completed, {failed} failed, {bytes} transferred',
|
||||
runtimeMetricActive: 'Active item',
|
||||
runtimeMetricLastError: 'Last error class',
|
||||
runtimeMetricLastErrorSummary: '{errorClass}, retry delay: {retryDelay} s',
|
||||
runtimeMetricErrorNetwork: 'Network',
|
||||
runtimeMetricErrorRateLimit: 'Rate limit',
|
||||
runtimeMetricErrorAuth: 'Authentication',
|
||||
runtimeMetricErrorTooling: 'External tools',
|
||||
runtimeMetricErrorIntegrity: 'Integrity',
|
||||
runtimeMetricErrorIo: 'File system',
|
||||
runtimeMetricErrorValidation: 'Validation',
|
||||
runtimeMetricErrorUnknown: 'Unknown',
|
||||
runtimeMetricUpdated: 'Updated',
|
||||
updateTitle: 'Updates',
|
||||
checkUpdates: 'Check for updates',
|
||||
|
||||
@@ -25,6 +25,19 @@ function createInput(value = '', checked = false): Input {
|
||||
return { value, checked };
|
||||
}
|
||||
|
||||
function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string | null) => string {
|
||||
const localeName = language === 'de' ? 'UI_TEXT_DE' : 'UI_TEXT_EN';
|
||||
const localeSource = fs.readFileSync(path.join(process.cwd(), 'src', `renderer-locale-${language}.ts`), 'utf8');
|
||||
const settingsSource = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8');
|
||||
const compiled = ts.transpileModule(
|
||||
`${localeSource}\nlet UI_TEXT = ${localeName};\n${settingsSource}\nglobalThis.__getRuntimeErrorClassLabel = getRuntimeErrorClassLabel;`,
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }
|
||||
).outputText;
|
||||
const context = vm.createContext({ console, window: {} });
|
||||
vm.runInContext(compiled, context);
|
||||
return context.__getRuntimeErrorClassLabel as (errorClass: string | null) => string;
|
||||
}
|
||||
|
||||
describe('renderer settings autosave orchestration', () => {
|
||||
it('persists a pure download policy change through the real autosave fingerprint', async () => {
|
||||
const inputs = new Map(inputIds.map((id) => [id, createInput()]));
|
||||
@@ -128,3 +141,25 @@ describe('renderer settings autosave orchestration', () => {
|
||||
expect(saveConfigCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderer runtime metrics localization', () => {
|
||||
it('renders every runtime error class and unknown values as human-readable German and English labels', () => {
|
||||
const german = loadRuntimeErrorFormatter('de');
|
||||
const english = loadRuntimeErrorFormatter('en');
|
||||
const cases = [
|
||||
{ errorClass: 'network', german: 'Netzwerk', english: 'Network' },
|
||||
{ errorClass: 'rate_limit', german: 'Anfragelimit', english: 'Rate limit' },
|
||||
{ errorClass: 'auth', german: 'Authentifizierung', english: 'Authentication' },
|
||||
{ errorClass: 'tooling', german: 'Externe Tools', english: 'External tools' },
|
||||
{ errorClass: 'integrity', german: 'Integrität', english: 'Integrity' },
|
||||
{ errorClass: 'io', german: 'Dateisystem', english: 'File system' },
|
||||
{ errorClass: 'validation', german: 'Validierung', english: 'Validation' },
|
||||
{ errorClass: 'unknown', german: 'Unbekannt', english: 'Unknown' },
|
||||
{ errorClass: 'future_error_class', german: 'Unbekannt', english: 'Unknown' },
|
||||
{ errorClass: null, german: '-', english: '-' }
|
||||
];
|
||||
|
||||
expect(cases.map(({ errorClass }) => german(errorClass))).toEqual(cases.map(({ german: label }) => label));
|
||||
expect(cases.map(({ errorClass }) => english(errorClass))).toEqual(cases.map(({ english: label }) => label));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,45 @@ function formatBytesForMetrics(bytes: number): string {
|
||||
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 {
|
||||
const templates = [
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
||||
@@ -123,15 +162,44 @@ async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
const lines = [
|
||||
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
|
||||
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
|
||||
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
|
||||
`${UI_TEXT.static.runtimeMetricQueue}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricQueueSummary, {
|
||||
total: metrics.queue.total,
|
||||
pending: metrics.queue.pending,
|
||||
downloading: metrics.queue.downloading,
|
||||
failed: metrics.queue.error
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricMode}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricModeSummary, {
|
||||
mode: getRuntimePerformanceModeLabel(metrics.config.performanceMode),
|
||||
smartScheduler: getRuntimeBooleanLabel(metrics.config.smartScheduler),
|
||||
duplicatePrevention: getRuntimeBooleanLabel(metrics.config.duplicatePrevention)
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricRetries}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricRetriesSummary, {
|
||||
scheduled: metrics.retriesScheduled,
|
||||
exhausted: metrics.retriesExhausted
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
|
||||
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
|
||||
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
|
||||
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
|
||||
`${UI_TEXT.static.runtimeMetricCache}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricCacheSummary, {
|
||||
hits: formatRuntimeMetricCount(metrics.cacheHits, UI_TEXT.static.runtimeMetricCacheHitOne, UI_TEXT.static.runtimeMetricCacheHitMany),
|
||||
misses: formatRuntimeMetricCount(metrics.cacheMisses, UI_TEXT.static.runtimeMetricCacheMissOne, UI_TEXT.static.runtimeMetricCacheMissMany),
|
||||
vods: formatRuntimeMetricCount(metrics.caches.vodList, UI_TEXT.static.runtimeMetricCacheVodOne, UI_TEXT.static.runtimeMetricCacheVodMany),
|
||||
users: formatRuntimeMetricCount(metrics.caches.loginToUserId, UI_TEXT.static.runtimeMetricCacheUserOne, UI_TEXT.static.runtimeMetricCacheUserMany),
|
||||
clips: formatRuntimeMetricCount(metrics.caches.clipInfo, UI_TEXT.static.runtimeMetricCacheClipOne, UI_TEXT.static.runtimeMetricCacheClipMany)
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricBandwidth}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricBandwidthSummary, {
|
||||
current: formatBytesForMetrics(metrics.lastSpeedBytesPerSec),
|
||||
average: formatBytesForMetrics(metrics.avgSpeedBytesPerSec)
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricDownloads}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricDownloadsSummary, {
|
||||
started: metrics.downloadsStarted,
|
||||
completed: metrics.downloadsCompleted,
|
||||
failed: metrics.downloadsFailed,
|
||||
bytes: formatBytesForMetrics(metrics.downloadedBytesTotal)
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
|
||||
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
|
||||
`${UI_TEXT.static.runtimeMetricLastError}: ${formatRuntimeMetricSummary(UI_TEXT.static.runtimeMetricLastErrorSummary, {
|
||||
errorClass: getRuntimeErrorClassLabel(metrics.lastErrorClass),
|
||||
retryDelay: metrics.lastRetryDelaySeconds
|
||||
})}`,
|
||||
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
|
||||
];
|
||||
|
||||
@@ -591,12 +659,12 @@ async function importConfigFromFile(): Promise<void> {
|
||||
const result = await window.api.importConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
invalidatePreflightResult();
|
||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||
setLanguage(config.language);
|
||||
invalidatePreflightResult();
|
||||
}
|
||||
if (typeof renderStreamers === 'function') renderStreamers();
|
||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||
@@ -604,6 +672,7 @@ async function importConfigFromFile(): Promise<void> {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore — next refresh will catch up */ }
|
||||
refreshLocalizedPreflightUi();
|
||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
|
||||
@@ -452,6 +452,10 @@ function initVodScrollTracking(): void {
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function isSupportedCutterVideoFile(file: { name: string }): boolean {
|
||||
return /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function initCutterDragDrop(): void {
|
||||
const tab = document.getElementById('cutterTab');
|
||||
if (!tab) return;
|
||||
@@ -485,8 +489,7 @@ function initCutterDragDrop(): void {
|
||||
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length === 0) return;
|
||||
const allowed = /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i;
|
||||
const file = files.find((entry) => allowed.test(entry.name));
|
||||
const file = files.find(isSupportedCutterVideoFile);
|
||||
if (!file) {
|
||||
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
|
||||
return;
|
||||
|
||||
+7
-3
@@ -3086,6 +3086,10 @@ input[type="checkbox"].vod-select-checkbox {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) and (min-height: 680px) {
|
||||
#cutterTab {
|
||||
overflow-y: hidden;
|
||||
@@ -3101,8 +3105,8 @@ input[type="checkbox"].vod-select-checkbox {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) {
|
||||
display: none;
|
||||
#cutterTab .cutter-container:has(.cutter-workspace.shown):has(> .cutter-recovery-panel:not([hidden])) {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
#cutterTab .cutter-workspace {
|
||||
@@ -3214,7 +3218,7 @@ input[type="checkbox"].vod-select-checkbox {
|
||||
|
||||
.cutter-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user