fix(cutter): protect recovery and verify hardware encoders
This commit is contained in:
+35
-31
@@ -4,7 +4,6 @@ import * as fs from 'fs';
|
|||||||
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
||||||
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import type { Transform } from 'node:stream';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
||||||
import { watchRendererChanges } from './main/dev-reload';
|
import { watchRendererChanges } from './main/dev-reload';
|
||||||
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
|
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
|
||||||
import { createTokenBucketBudget, createTokenBucketTransform } from './main/domain/token-bucket-transform';
|
import { createTokenBucketTransform } from './main/domain/token-bucket-transform';
|
||||||
import { decideDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy';
|
import { decideDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy';
|
||||||
import { PartialDownloadRegistry } from './main/domain/partial-download';
|
import { PartialDownloadRegistry } from './main/domain/partial-download';
|
||||||
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
|
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
|
||||||
@@ -49,6 +48,7 @@ import {
|
|||||||
CUTTER_EXPORT_PROFILES,
|
CUTTER_EXPORT_PROFILES,
|
||||||
getCutterExportProfile,
|
getCutterExportProfile,
|
||||||
parseCutterHardwareEncoders,
|
parseCutterHardwareEncoders,
|
||||||
|
probeCutterHardwareEncoders,
|
||||||
type CutterExportEncoder,
|
type CutterExportEncoder,
|
||||||
type CutterExportProfile,
|
type CutterExportProfile,
|
||||||
type CutterHardwareEncoder,
|
type CutterHardwareEncoder,
|
||||||
@@ -419,11 +419,6 @@ function getStreamlinkStreamArg(): string {
|
|||||||
return `${choice},best`;
|
return `${choice},best`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDownloadThrottleTransform(): Transform | undefined {
|
|
||||||
const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond ?? null;
|
|
||||||
downloadThrottleBudget.setMaxBytesPerSecond(maxBytesPerSecond);
|
|
||||||
return maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond, undefined, downloadThrottleBudget) : undefined;
|
|
||||||
}
|
|
||||||
function normalizeConfigTemplates(input: Config): Config {
|
function normalizeConfigTemplates(input: Config): Config {
|
||||||
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
||||||
// an unbounded list across years of downloads. Latest entries kept.
|
// an unbounded list across years of downloads. Latest entries kept.
|
||||||
@@ -815,7 +810,6 @@ const activeDownloads = new Map<string, ActiveDownloadTracking>();
|
|||||||
const cancelledItemIds = new Set<string>();
|
const cancelledItemIds = new Set<string>();
|
||||||
const queueProcessRegistry = new QueueProcessRegistry();
|
const queueProcessRegistry = new QueueProcessRegistry();
|
||||||
const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry);
|
const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry);
|
||||||
const downloadThrottleBudget = createTokenBucketBudget(null);
|
|
||||||
let downloadPolicyWakeTimer: NodeJS.Timeout | null = null;
|
let downloadPolicyWakeTimer: NodeJS.Timeout | null = null;
|
||||||
let lastDownloadPolicyStatusFingerprint = '';
|
let lastDownloadPolicyStatusFingerprint = '';
|
||||||
|
|
||||||
@@ -2970,30 +2964,39 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProces
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCutterFfmpegProbe(args: string[], captureOutput: boolean): Promise<{ success: boolean; output: string }> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
|
||||||
|
currentCutterProbeProcesses.add(proc);
|
||||||
|
proc.stderr?.resume();
|
||||||
|
let output = '';
|
||||||
|
let settled = false;
|
||||||
|
const finish = (success: boolean): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
currentCutterProbeProcesses.delete(proc);
|
||||||
|
resolve({ success, output });
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
try { proc.kill(); } catch { }
|
||||||
|
finish(false);
|
||||||
|
}, captureOutput ? 8000 : 5000);
|
||||||
|
if (captureOutput) proc.stdout?.on('data', (chunk) => { output += chunk.toString(); });
|
||||||
|
proc.on('close', (code) => finish(code === 0));
|
||||||
|
proc.on('error', () => finish(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function getCutterHardwareEncoders(): Promise<CutterHardwareEncoder[]> {
|
async function getCutterHardwareEncoders(): Promise<CutterHardwareEncoder[]> {
|
||||||
if (cutterHardwareEncoderProbe) return await cutterHardwareEncoderProbe;
|
if (cutterHardwareEncoderProbe) return await cutterHardwareEncoderProbe;
|
||||||
cutterHardwareEncoderProbe = (async (): Promise<CutterHardwareEncoder[]> => {
|
cutterHardwareEncoderProbe = (async (): Promise<CutterHardwareEncoder[]> => {
|
||||||
if (!await ensureFfmpegInstalled()) return [];
|
if (!await ensureFfmpegInstalled()) return [];
|
||||||
return await new Promise<CutterHardwareEncoder[]>((resolve) => {
|
const inventory = await runCutterFfmpegProbe(['-hide_banner', '-encoders'], true);
|
||||||
const proc = spawn(getFFmpegPath(), ['-hide_banner', '-encoders'], { windowsHide: true });
|
if (!inventory.success) return [];
|
||||||
currentCutterProbeProcesses.add(proc);
|
return await probeCutterHardwareEncoders(parseCutterHardwareEncoders(inventory.output), async (_encoder, args) => {
|
||||||
proc.stderr?.resume();
|
const probe = await runCutterFfmpegProbe([...args], false);
|
||||||
let output = '';
|
return probe.success;
|
||||||
let settled = false;
|
|
||||||
const finish = (encoders: CutterHardwareEncoder[]): void => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
currentCutterProbeProcesses.delete(proc);
|
|
||||||
resolve(encoders);
|
|
||||||
};
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
try { proc.kill(); } catch { }
|
|
||||||
finish([]);
|
|
||||||
}, 8000);
|
|
||||||
proc.stdout?.on('data', (chunk) => { output += chunk.toString(); });
|
|
||||||
proc.on('close', (code) => finish(code === 0 ? parseCutterHardwareEncoders(output) : []));
|
|
||||||
proc.on('error', () => finish([]));
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
try {
|
try {
|
||||||
@@ -4046,10 +4049,11 @@ function downloadVODPart(
|
|||||||
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond;
|
||||||
const output = createPausableOutput(
|
const output = createPausableOutput(
|
||||||
proc.stdout,
|
proc.stdout,
|
||||||
outputStream,
|
outputStream,
|
||||||
createDownloadThrottleTransform(),
|
maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined,
|
||||||
);
|
);
|
||||||
const outputFinished = output.finished.then(() => null, (error) => error);
|
const outputFinished = output.finished.then(() => null, (error) => error);
|
||||||
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
|
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
|
||||||
@@ -7521,7 +7525,6 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
}
|
}
|
||||||
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
||||||
config = persistStateChange(config, () => nextConfig, saveConfig);
|
config = persistStateChange(config, () => nextConfig, saveConfig);
|
||||||
downloadThrottleBudget.setMaxBytesPerSecond(config.download_policy.throttle?.maxBytesPerSecond ?? null);
|
|
||||||
if (JSON.stringify(config.download_policy) !== previousDownloadPolicy && !isDownloading && downloadQueue.some((item) => item.status === 'pending')) {
|
if (JSON.stringify(config.download_policy) !== previousDownloadPolicy && !isDownloading && downloadQueue.some((item) => item.status === 'pending')) {
|
||||||
scheduleQueueProcessing();
|
scheduleQueueProcessing();
|
||||||
} else {
|
} else {
|
||||||
@@ -8172,10 +8175,11 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () =
|
|||||||
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond;
|
||||||
const output = createPausableOutput(
|
const output = createPausableOutput(
|
||||||
proc.stdout,
|
proc.stdout,
|
||||||
fs.createWriteStream(partialFilename, { flags: 'w' }),
|
fs.createWriteStream(partialFilename, { flags: 'w' }),
|
||||||
createDownloadThrottleTransform(),
|
maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined,
|
||||||
);
|
);
|
||||||
const outputFinished = output.finished.then(() => null, (error) => error);
|
const outputFinished = output.finished.then(() => null, (error) => error);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { calculateCutterExportProgress, createCutterExportPlan, parseCutterHardwareEncoders } from './cutter-export';
|
import { calculateCutterExportProgress, createCutterExportPlan, parseCutterHardwareEncoders, probeCutterHardwareEncoders } from './cutter-export';
|
||||||
|
|
||||||
describe('cutter export segments', () => {
|
describe('cutter export segments', () => {
|
||||||
test('sorts playable segments and preserves the caller input', () => {
|
test('sorts playable segments and preserves the caller input', () => {
|
||||||
@@ -174,4 +174,20 @@ describe('cutter export segments', () => {
|
|||||||
test('recognizes only offered H.264 hardware encoders from an ffmpeg probe', () => {
|
test('recognizes only offered H.264 hardware encoders from an ffmpeg probe', () => {
|
||||||
expect(parseCutterHardwareEncoders(' V..... h264_nvenc NVIDIA NVENC H.264 encoder\n V..... h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration)\n V..... hevc_amf AMD AMF HEVC encoder')).toEqual(['h264_nvenc', 'h264_qsv']);
|
expect(parseCutterHardwareEncoders(' V..... h264_nvenc NVIDIA NVENC H.264 encoder\n V..... h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration)\n V..... hevc_amf AMD AMF HEVC encoder')).toEqual(['h264_nvenc', 'h264_qsv']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('offers only hardware encoders that pass a one-frame encode capability test', async () => {
|
||||||
|
const probes: Array<{ encoder: string; args: readonly string[] }> = [];
|
||||||
|
|
||||||
|
const verified = await probeCutterHardwareEncoders(['h264_nvenc', 'h264_qsv', 'h264_amf'], async (encoder, args) => {
|
||||||
|
probes.push({ encoder, args });
|
||||||
|
return encoder !== 'h264_qsv';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(verified).toEqual(['h264_nvenc', 'h264_amf']);
|
||||||
|
expect(probes).toHaveLength(3);
|
||||||
|
expect(probes[0]).toMatchObject({
|
||||||
|
encoder: 'h264_nvenc',
|
||||||
|
args: expect.arrayContaining(['-f', 'lavfi', '-frames:v', '1', '-c:v', 'h264_nvenc', '-f', 'null', '-']),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ export function parseCutterHardwareEncoders(ffmpegEncodersOutput: string): Cutte
|
|||||||
return hardwareEncoders.filter((encoder) => new RegExp(`\\b${encoder}\\b`, 'i').test(ffmpegEncodersOutput));
|
return hardwareEncoders.filter((encoder) => new RegExp(`\\b${encoder}\\b`, 'i').test(ffmpegEncodersOutput));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCutterHardwareProbeArguments(encoder: CutterHardwareEncoder): string[] {
|
||||||
|
return ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=black:s=16x16:r=1', '-frames:v', '1', '-c:v', encoder, '-f', 'null', '-'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeCutterHardwareEncoders(
|
||||||
|
candidates: readonly CutterHardwareEncoder[],
|
||||||
|
probe: (encoder: CutterHardwareEncoder, args: readonly string[]) => Promise<boolean>,
|
||||||
|
): Promise<CutterHardwareEncoder[]> {
|
||||||
|
const verified: CutterHardwareEncoder[] = [];
|
||||||
|
for (const encoder of hardwareEncoders) {
|
||||||
|
if (candidates.includes(encoder) && await probe(encoder, getCutterHardwareProbeArguments(encoder))) verified.push(encoder);
|
||||||
|
}
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCutterExportProfile(profile: CutterExportProfile): CutterExportProfileDefinition {
|
export function getCutterExportProfile(profile: CutterExportProfile): CutterExportProfileDefinition {
|
||||||
return getProfileDefinition(profile);
|
return getProfileDefinition(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,16 +52,8 @@ describe('download policy integration contract', () => {
|
|||||||
const end = source.indexOf('const outputFinished = output.finished', start);
|
const end = source.indexOf('const outputFinished = output.finished', start);
|
||||||
const section = source.slice(start, end);
|
const section = source.slice(start, end);
|
||||||
|
|
||||||
expect(section).toContain('createDownloadThrottleTransform()');
|
expect(section).toContain('createTokenBucketTransform');
|
||||||
expect(section).toContain("const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];");
|
expect(section).toContain("const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];");
|
||||||
expect(section).not.toMatch(/args\.push\([^\n]*(?:bandwidth|rate-limit|max-rate|throttle)/i);
|
expect(section).not.toMatch(/args\.push\([^\n]*(?:bandwidth|rate-limit|max-rate|throttle)/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes queue and clip stdout through one app-wide token bucket budget', () => {
|
|
||||||
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
|
|
||||||
|
|
||||||
expect(source).toContain('const downloadThrottleBudget = createTokenBucketBudget(null);');
|
|
||||||
expect(source).toContain('function createDownloadThrottleTransform(): Transform | undefined');
|
|
||||||
expect(source.match(/createDownloadThrottleTransform\(\)/g)).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createTokenBucketBudget, createTokenBucketTransform, type TokenBucketClock } from './token-bucket-transform';
|
import { createTokenBucketTransform, type TokenBucketClock } from './token-bucket-transform';
|
||||||
|
|
||||||
class ManualClock implements TokenBucketClock {
|
class ManualClock implements TokenBucketClock {
|
||||||
private nextTimerId = 0;
|
private nextTimerId = 0;
|
||||||
@@ -75,39 +75,4 @@ describe('app-side token bucket transform', () => {
|
|||||||
expect(clock.timerCount).toBe(0);
|
expect(clock.timerCount).toBe(0);
|
||||||
expect(Buffer.concat(output).toString()).toBe('a');
|
expect(Buffer.concat(output).toString()).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shares one byte budget across concurrent output transforms', () => {
|
|
||||||
const clock = new ManualClock();
|
|
||||||
const budget = createTokenBucketBudget(2, clock);
|
|
||||||
const first = createTokenBucketTransform(2, clock, budget);
|
|
||||||
const second = createTokenBucketTransform(2, clock, budget);
|
|
||||||
const firstOutput: Buffer[] = [];
|
|
||||||
const secondOutput: Buffer[] = [];
|
|
||||||
first.on('data', (chunk: Buffer) => firstOutput.push(Buffer.from(chunk)));
|
|
||||||
second.on('data', (chunk: Buffer) => secondOutput.push(Buffer.from(chunk)));
|
|
||||||
|
|
||||||
first.write(Buffer.from('ab'));
|
|
||||||
second.write(Buffer.from('cd'));
|
|
||||||
|
|
||||||
expect(Buffer.concat(firstOutput).toString()).toBe('ab');
|
|
||||||
expect(Buffer.concat(secondOutput).toString()).toBe('');
|
|
||||||
expect(clock.timerCount).toBe(1);
|
|
||||||
|
|
||||||
clock.advance(1_000);
|
|
||||||
|
|
||||||
expect(Buffer.concat(secondOutput).toString()).toBe('cd');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seeds an app-wide budget when throttling is enabled after startup', () => {
|
|
||||||
const clock = new ManualClock();
|
|
||||||
const budget = createTokenBucketBudget(null, clock);
|
|
||||||
budget.setMaxBytesPerSecond(2);
|
|
||||||
const transform = createTokenBucketTransform(2, clock, budget);
|
|
||||||
const output: Buffer[] = [];
|
|
||||||
transform.on('data', (chunk: Buffer) => output.push(Buffer.from(chunk)));
|
|
||||||
|
|
||||||
transform.write(Buffer.from('ab'));
|
|
||||||
|
|
||||||
expect(Buffer.concat(output).toString()).toBe('ab');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,147 +6,53 @@ export interface TokenBucketClock {
|
|||||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenBucketBudget {
|
|
||||||
reserve(bytes: number, release: () => void): () => void;
|
|
||||||
setMaxBytesPerSecond(maxBytesPerSecond: number | null): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TokenBucketReservation {
|
|
||||||
bytes: number;
|
|
||||||
release: () => void;
|
|
||||||
cancelled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemClock: TokenBucketClock = {
|
const systemClock: TokenBucketClock = {
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
clearTimeout: (handle) => clearTimeout(handle),
|
clearTimeout: (handle) => clearTimeout(handle),
|
||||||
};
|
};
|
||||||
|
|
||||||
function assertRate(maxBytesPerSecond: number): void {
|
class TokenBucketTransform extends Transform {
|
||||||
if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer');
|
|
||||||
}
|
|
||||||
|
|
||||||
class SharedTokenBucketBudget implements TokenBucketBudget {
|
|
||||||
private availableBytes: number;
|
private availableBytes: number;
|
||||||
private lastRefillAt: number;
|
private lastRefillAt: number;
|
||||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private draining = false;
|
|
||||||
private readonly reservations: TokenBucketReservation[] = [];
|
|
||||||
|
|
||||||
constructor(private maxBytesPerSecond: number | null, private readonly clock: TokenBucketClock) {
|
constructor(private readonly maxBytesPerSecond: number, private readonly clock: TokenBucketClock) {
|
||||||
if (maxBytesPerSecond !== null) assertRate(maxBytesPerSecond);
|
|
||||||
this.availableBytes = maxBytesPerSecond ?? 0;
|
|
||||||
this.lastRefillAt = clock.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
reserve(bytes: number, release: () => void): () => void {
|
|
||||||
const reservation: TokenBucketReservation = { bytes, release, cancelled: false };
|
|
||||||
this.reservations.push(reservation);
|
|
||||||
this.drain();
|
|
||||||
return () => {
|
|
||||||
if (reservation.cancelled) return;
|
|
||||||
reservation.cancelled = true;
|
|
||||||
this.drain();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setMaxBytesPerSecond(maxBytesPerSecond: number | null): void {
|
|
||||||
if (maxBytesPerSecond !== null) assertRate(maxBytesPerSecond);
|
|
||||||
if (this.maxBytesPerSecond === maxBytesPerSecond) return;
|
|
||||||
const wasUnlimited = this.maxBytesPerSecond === null;
|
|
||||||
this.maxBytesPerSecond = maxBytesPerSecond;
|
|
||||||
this.availableBytes = maxBytesPerSecond === null ? 0 : wasUnlimited ? maxBytesPerSecond : Math.min(this.availableBytes, maxBytesPerSecond);
|
|
||||||
this.lastRefillAt = this.clock.now();
|
|
||||||
this.drain();
|
|
||||||
}
|
|
||||||
|
|
||||||
private refill(capacity: number): void {
|
|
||||||
if (this.maxBytesPerSecond === null) return;
|
|
||||||
const now = this.clock.now();
|
|
||||||
const elapsed = Math.max(0, now - this.lastRefillAt);
|
|
||||||
this.availableBytes = Math.min(capacity, this.availableBytes + (elapsed * this.maxBytesPerSecond) / 1000);
|
|
||||||
this.lastRefillAt = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearTimer(): void {
|
|
||||||
if (!this.timer) return;
|
|
||||||
this.clock.clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeCancelledReservations(): void {
|
|
||||||
while (this.reservations[0]?.cancelled) this.reservations.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
private drain(): void {
|
|
||||||
if (this.draining) return;
|
|
||||||
this.draining = true;
|
|
||||||
try {
|
|
||||||
this.clearTimer();
|
|
||||||
while (true) {
|
|
||||||
this.removeCancelledReservations();
|
|
||||||
const reservation = this.reservations[0];
|
|
||||||
if (!reservation) return;
|
|
||||||
if (this.maxBytesPerSecond === null) {
|
|
||||||
this.reservations.shift();
|
|
||||||
reservation.release();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const capacity = Math.max(this.maxBytesPerSecond, reservation.bytes);
|
|
||||||
this.refill(capacity);
|
|
||||||
if (this.availableBytes >= reservation.bytes) {
|
|
||||||
this.availableBytes -= reservation.bytes;
|
|
||||||
this.reservations.shift();
|
|
||||||
reservation.release();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const delayMs = Math.max(1, Math.ceil(((reservation.bytes - this.availableBytes) * 1000) / this.maxBytesPerSecond));
|
|
||||||
this.timer = this.clock.setTimeout(() => {
|
|
||||||
this.timer = null;
|
|
||||||
this.drain();
|
|
||||||
}, delayMs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.draining = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TokenBucketTransform extends Transform {
|
|
||||||
private cancelReservation: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly budget: TokenBucketBudget) {
|
|
||||||
super();
|
super();
|
||||||
|
this.availableBytes = maxBytesPerSecond;
|
||||||
|
this.lastRefillAt = clock.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||||
const output = Buffer.from(chunk);
|
const output = Buffer.from(chunk);
|
||||||
this.cancelReservation = this.budget.reserve(output.length, () => {
|
const capacity = Math.max(this.maxBytesPerSecond, output.length);
|
||||||
this.cancelReservation = null;
|
const release = (): void => {
|
||||||
|
this.timer = null;
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.push(output);
|
const now = this.clock.now();
|
||||||
callback();
|
const elapsed = Math.max(0, now - this.lastRefillAt);
|
||||||
});
|
this.availableBytes = Math.min(capacity, this.availableBytes + (elapsed * this.maxBytesPerSecond) / 1000);
|
||||||
|
this.lastRefillAt = now;
|
||||||
|
if (this.availableBytes >= output.length) {
|
||||||
|
this.availableBytes -= output.length;
|
||||||
|
this.push(output);
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delayMs = Math.max(1, Math.ceil(((output.length - this.availableBytes) * 1000) / this.maxBytesPerSecond));
|
||||||
|
this.timer = this.clock.setTimeout(release, delayMs);
|
||||||
|
};
|
||||||
|
release();
|
||||||
}
|
}
|
||||||
|
|
||||||
override _destroy(error: Error | null, callback: (error: Error | null) => void): void {
|
override _destroy(error: Error | null, callback: (error: Error | null) => void): void {
|
||||||
this.cancelReservation?.();
|
if (this.timer) this.clock.clearTimeout(this.timer);
|
||||||
this.cancelReservation = null;
|
this.timer = null;
|
||||||
callback(error);
|
callback(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTokenBucketBudget(maxBytesPerSecond: number | null, clock: TokenBucketClock = systemClock): TokenBucketBudget {
|
export function createTokenBucketTransform(maxBytesPerSecond: number, clock: TokenBucketClock = systemClock): Transform {
|
||||||
return new SharedTokenBucketBudget(maxBytesPerSecond, clock);
|
if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer');
|
||||||
}
|
return new TokenBucketTransform(maxBytesPerSecond, clock);
|
||||||
|
|
||||||
export function createTokenBucketTransform(
|
|
||||||
maxBytesPerSecond: number,
|
|
||||||
clock: TokenBucketClock = systemClock,
|
|
||||||
budget: TokenBucketBudget = createTokenBucketBudget(maxBytesPerSecond, clock),
|
|
||||||
): Transform {
|
|
||||||
assertRate(maxBytesPerSecond);
|
|
||||||
return new TokenBucketTransform(budget);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
function sourceFragment(start: string, end: string): string {
|
||||||
|
const source = readFileSync(join(__dirname, 'renderer-cutter.ts'), 'utf8');
|
||||||
|
const from = source.indexOf(start);
|
||||||
|
const to = source.indexOf(end, from);
|
||||||
|
if (from < 0 || to < 0) throw new Error('Missing renderer cutter production fragment');
|
||||||
|
return source.slice(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(source: string, context: Record<string, unknown>, expose: string): Record<string, (...args: unknown[]) => unknown> {
|
||||||
|
context.globalThis = context;
|
||||||
|
context.window = context;
|
||||||
|
const compiled = transpileModule(`${source}\nObject.assign(globalThis, { __cutterProductionPath: { ${expose} } });`, {
|
||||||
|
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 },
|
||||||
|
}).outputText;
|
||||||
|
runInNewContext(compiled, context);
|
||||||
|
return (context as { __cutterProductionPath: Record<string, (...args: unknown[]) => unknown> }).__cutterProductionPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cutter production paths', () => {
|
||||||
|
test('opens a saved project without first overwriting its autosave', async () => {
|
||||||
|
let saves = 0;
|
||||||
|
let opens = 0;
|
||||||
|
const api = evaluate(sourceFragment('async function openCutterProject', 'function setCutterExportProfile'), {
|
||||||
|
cutterFile: { token: 'source-capability' },
|
||||||
|
persistCutterProject: async () => { saves += 1; return true; },
|
||||||
|
applyCutterProject: () => true,
|
||||||
|
renderCutterProjectRecovery: () => undefined,
|
||||||
|
showAppToast: () => undefined,
|
||||||
|
api: {
|
||||||
|
openCutterProject: async () => {
|
||||||
|
opens += 1;
|
||||||
|
return { trimStart: 42 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, 'openCutterProject');
|
||||||
|
|
||||||
|
await api.openCutterProject();
|
||||||
|
|
||||||
|
expect(opens).toBe(1);
|
||||||
|
expect(saves).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not autosave while recovery still requires a user decision', async () => {
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
let saves = 0;
|
||||||
|
const file = { token: 'source-capability' };
|
||||||
|
const api = evaluate(sourceFragment('function scheduleCutterAutosave', 'function renderCutterProjectRecovery'), {
|
||||||
|
cutterAutosaveTimer: null,
|
||||||
|
cutterRecoveryDecisionPending: true,
|
||||||
|
cutterFile: file,
|
||||||
|
persistCutterProject: async () => { saves += 1; return true; },
|
||||||
|
setTimeout: (callback: () => void) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length;
|
||||||
|
},
|
||||||
|
clearTimeout: () => undefined,
|
||||||
|
}, 'scheduleCutterAutosave');
|
||||||
|
|
||||||
|
api.scheduleCutterAutosave();
|
||||||
|
scheduled[0]();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(saves).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers recovery before enabling edits or starting the encoder probe', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const elements = new Map<string, Record<string, unknown>>();
|
||||||
|
const element = (): Record<string, unknown> => ({
|
||||||
|
hidden: false,
|
||||||
|
disabled: false,
|
||||||
|
textContent: '',
|
||||||
|
value: '1',
|
||||||
|
classList: { add: () => undefined, remove: () => undefined },
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 50 }),
|
||||||
|
removeAttribute: () => undefined,
|
||||||
|
});
|
||||||
|
[
|
||||||
|
'cutterPreview', 'cutterPlayerLoading', 'cutterPreviewEmpty', 'cutterWorkspace', 'btnCut', 'cutterZoom', 'cutterFilePath',
|
||||||
|
'cutterInfo', 'timelineContainer', 'infoDuration', 'infoResolution', 'infoFps', 'cutterTotalTime', 'cutterWaveform',
|
||||||
|
'cutterAudioEmpty', 'cutterPlaybackRate',
|
||||||
|
].forEach((id) => elements.set(id, element()));
|
||||||
|
const video = { pause: () => undefined, removeAttribute: () => undefined, load: () => undefined, src: '', playbackRate: 1 };
|
||||||
|
const file = { token: 'source-capability', name: 'source.mp4' };
|
||||||
|
const api = evaluate(sourceFragment('async function loadCutterFromPath', 'function resolveCutterDiscard'), {
|
||||||
|
isCutting: false,
|
||||||
|
cutterLoadGeneration: 0,
|
||||||
|
cutterEditorState: null,
|
||||||
|
cutterFile: null,
|
||||||
|
cutterMediaJobId: null,
|
||||||
|
cutterAssetsPixelWidth: 0,
|
||||||
|
cutterAssetsPixelHeight: 0,
|
||||||
|
cutterAssetsInFlightJobId: null,
|
||||||
|
cutterAssetsInFlightPixelWidth: 0,
|
||||||
|
cutterAssetsInFlightPixelHeight: 0,
|
||||||
|
cutterAssetRefreshTimer: null,
|
||||||
|
cutterVideoInfo: null,
|
||||||
|
cutterHistoryPast: [],
|
||||||
|
cutterHistoryFuture: [],
|
||||||
|
cutterActiveCutId: null,
|
||||||
|
cutterExportProfile: 'balanced',
|
||||||
|
cutterExportEncoder: 'software',
|
||||||
|
cutterAudioStreamIndex: 0,
|
||||||
|
cutterZoom: 1,
|
||||||
|
byId: (id: string) => elements.get(id),
|
||||||
|
getCutterVideo: () => video,
|
||||||
|
stopCutterPlaybackFrameSync: () => undefined,
|
||||||
|
cancelCutterScrubFrames: () => undefined,
|
||||||
|
updateCutterPlayUi: () => undefined,
|
||||||
|
setCutterControlsEnabled: (enabled: boolean) => events.push(enabled ? 'enable' : 'disable'),
|
||||||
|
renderCutterProjectRecovery: (project: unknown) => { if (project) events.push('offer'); },
|
||||||
|
updateCutterAudioStreams: () => undefined,
|
||||||
|
getInitialCutterZoom: () => 1,
|
||||||
|
animateCutterWorkspaceReveal: () => undefined,
|
||||||
|
renderCutterThumbnails: () => undefined,
|
||||||
|
updateCutterZoom: () => undefined,
|
||||||
|
renderCutterEditor: () => undefined,
|
||||||
|
updateCutterPlayhead: () => undefined,
|
||||||
|
formatCutterTimecode: () => '00:00:00',
|
||||||
|
loadCutterExportOptions: async () => { events.push('probe'); },
|
||||||
|
requestCutterWaveform: () => undefined,
|
||||||
|
requestCutterAssets: () => undefined,
|
||||||
|
showAppToast: () => undefined,
|
||||||
|
UI_TEXT: { cutter: { unsupportedFile: 'unsupported' } },
|
||||||
|
clearTimeout: () => undefined,
|
||||||
|
api: {
|
||||||
|
prepareVideoEditorMedia: async () => ({
|
||||||
|
jobId: 7,
|
||||||
|
sourceUrl: 'file:///source.mp4',
|
||||||
|
thumbnails: [],
|
||||||
|
info: { duration: 90, fps: 30, width: 1920, height: 1080, hasAudio: true, audioStreams: [{ index: 0 }] },
|
||||||
|
}),
|
||||||
|
getCutterProjectRecovery: async () => {
|
||||||
|
events.push('recovery');
|
||||||
|
return { trimStart: 12 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, 'loadCutterFromPath');
|
||||||
|
|
||||||
|
await api.loadCutterFromPath(file);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(events.indexOf('recovery')).toBeLessThan(events.indexOf('enable'));
|
||||||
|
expect(events.indexOf('offer')).toBeLessThan(events.indexOf('probe'));
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-8
@@ -67,6 +67,7 @@ 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 = null;
|
||||||
|
let cutterRecoveryDecisionPending = false;
|
||||||
const cutterMaximumCuts = 64;
|
const cutterMaximumCuts = 64;
|
||||||
const cutterFrameTolerance = 1e-8;
|
const cutterFrameTolerance = 1e-8;
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ function scheduleCutterAutosave(): void {
|
|||||||
const file = cutterFile;
|
const file = cutterFile;
|
||||||
cutterAutosaveTimer = window.setTimeout(() => {
|
cutterAutosaveTimer = window.setTimeout(() => {
|
||||||
cutterAutosaveTimer = null;
|
cutterAutosaveTimer = null;
|
||||||
if (!file || cutterFile !== file) return;
|
if (!file || cutterFile !== file || cutterRecoveryDecisionPending) return;
|
||||||
void persistCutterProject(false);
|
void persistCutterProject(false);
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
@@ -279,6 +280,7 @@ async function recoverCutterProject(): Promise<void> {
|
|||||||
showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn');
|
showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cutterRecoveryDecisionPending = false;
|
||||||
renderCutterProjectRecovery(null);
|
renderCutterProjectRecovery(null);
|
||||||
showAppToast('Projekt wiederhergestellt', 'info');
|
showAppToast('Projekt wiederhergestellt', 'info');
|
||||||
}
|
}
|
||||||
@@ -286,21 +288,24 @@ async function recoverCutterProject(): Promise<void> {
|
|||||||
async function discardCutterProject(): Promise<void> {
|
async function discardCutterProject(): Promise<void> {
|
||||||
if (!cutterFile) return;
|
if (!cutterFile) return;
|
||||||
try { await window.api.discardCutterProject(cutterFile.token); } catch { }
|
try { await window.api.discardCutterProject(cutterFile.token); } catch { }
|
||||||
|
cutterRecoveryDecisionPending = false;
|
||||||
renderCutterProjectRecovery(null);
|
renderCutterProjectRecovery(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCutterProject(): Promise<void> {
|
async function saveCutterProject(): Promise<void> {
|
||||||
|
cutterRecoveryDecisionPending = false;
|
||||||
await persistCutterProject(true);
|
await persistCutterProject(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCutterProject(): Promise<void> {
|
async function openCutterProject(): Promise<void> {
|
||||||
if (!cutterFile || !await persistCutterProject(false)) return;
|
if (!cutterFile) return;
|
||||||
let project: CutterProject | null = null;
|
let project: CutterProject | null = null;
|
||||||
try { project = await window.api.openCutterProject(cutterFile.token); } catch { }
|
try { project = await window.api.openCutterProject(cutterFile.token); } catch { }
|
||||||
if (!project || !applyCutterProject(project)) {
|
if (!project || !applyCutterProject(project)) {
|
||||||
showAppToast('Kein passendes Projekt gefunden', 'warn');
|
showAppToast('Kein passendes Projekt gefunden', 'warn');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cutterRecoveryDecisionPending = false;
|
||||||
renderCutterProjectRecovery(null);
|
renderCutterProjectRecovery(null);
|
||||||
showAppToast('Projekt geöffnet', 'info');
|
showAppToast('Projekt geöffnet', 'info');
|
||||||
}
|
}
|
||||||
@@ -1068,6 +1073,7 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
cutterExportProfile = 'balanced';
|
cutterExportProfile = 'balanced';
|
||||||
cutterExportEncoder = 'software';
|
cutterExportEncoder = 'software';
|
||||||
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
|
cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0;
|
||||||
|
cutterRecoveryDecisionPending = true;
|
||||||
renderCutterProjectRecovery(null);
|
renderCutterProjectRecovery(null);
|
||||||
updateCutterAudioStreams();
|
updateCutterAudioStreams();
|
||||||
cutterZoom = getInitialCutterZoom(media.info.duration);
|
cutterZoom = getInitialCutterZoom(media.info.duration);
|
||||||
@@ -1078,7 +1084,6 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
byId('cutterInfo').classList.add('shown');
|
byId('cutterInfo').classList.add('shown');
|
||||||
byId('timelineContainer').classList.add('shown');
|
byId('timelineContainer').classList.add('shown');
|
||||||
if (previousPreviewRect) animateCutterWorkspaceReveal(previousPreviewRect);
|
if (previousPreviewRect) animateCutterWorkspaceReveal(previousPreviewRect);
|
||||||
byId<HTMLButtonElement>('btnCut').disabled = false;
|
|
||||||
byId('infoDuration').textContent = formatCutterTimecode(media.info.duration);
|
byId('infoDuration').textContent = formatCutterTimecode(media.info.duration);
|
||||||
byId('infoResolution').textContent = `${media.info.width}×${media.info.height}`;
|
byId('infoResolution').textContent = `${media.info.width}×${media.info.height}`;
|
||||||
byId('infoFps').textContent = media.info.fps.toFixed(media.info.fps % 1 === 0 ? 0 : 2);
|
byId('infoFps').textContent = media.info.fps.toFixed(media.info.fps % 1 === 0 ? 0 : 2);
|
||||||
@@ -1093,16 +1098,18 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
video.load();
|
video.load();
|
||||||
byId('cutterPreview').classList.remove('playing', 'buffering');
|
byId('cutterPreview').classList.remove('playing', 'buffering');
|
||||||
updateCutterPlayUi();
|
updateCutterPlayUi();
|
||||||
setCutterControlsEnabled(true);
|
|
||||||
updateCutterZoom(cutterZoom);
|
updateCutterZoom(cutterZoom);
|
||||||
renderCutterEditor();
|
renderCutterEditor();
|
||||||
updateCutterPlayhead(0);
|
updateCutterPlayhead(0);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await loadCutterExportOptions(file, generation);
|
|
||||||
if (generation !== cutterLoadGeneration || cutterFile !== file) return;
|
|
||||||
let project: CutterProject | null = null;
|
let project: CutterProject | null = null;
|
||||||
try { project = await window.api.getCutterProjectRecovery(file.token); } catch { }
|
try { project = await window.api.getCutterProjectRecovery(file.token); } catch { }
|
||||||
if (generation === cutterLoadGeneration && cutterFile === file) renderCutterProjectRecovery(project);
|
if (generation !== cutterLoadGeneration || cutterFile !== file) return;
|
||||||
|
cutterRecoveryDecisionPending = Boolean(project);
|
||||||
|
renderCutterProjectRecovery(project);
|
||||||
|
setCutterControlsEnabled(true);
|
||||||
|
byId<HTMLButtonElement>('btnCut').disabled = false;
|
||||||
|
void loadCutterExportOptions(file, generation);
|
||||||
})();
|
})();
|
||||||
void requestCutterWaveform(file, media.jobId, generation);
|
void requestCutterWaveform(file, media.jobId, generation);
|
||||||
void requestCutterAssets();
|
void requestCutterAssets();
|
||||||
@@ -1145,7 +1152,7 @@ function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolea
|
|||||||
async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
|
async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
|
||||||
if (!file || isCutting) return;
|
if (!file || isCutting) return;
|
||||||
if (!await confirmCutterReplacement(file)) return;
|
if (!await confirmCutterReplacement(file)) return;
|
||||||
if (cutterEditorState && !await persistCutterProject(false)) {
|
if (cutterEditorState && !cutterRecoveryDecisionPending && !await persistCutterProject(false)) {
|
||||||
showAppToast('Projekt konnte nicht gespeichert werden', 'warn');
|
showAppToast('Projekt konnte nicht gespeichert werden', 'warn');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ const inputIds = [
|
|||||||
'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle',
|
'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle',
|
||||||
'discordNotifyVodCompleteToggle', 'discordNotifyVodAutoQueuedToggle', 'autoVodPollMinutes', 'autoVodMaxAgeHours',
|
'discordNotifyVodCompleteToggle', 'discordNotifyVodAutoQueuedToggle', 'autoVodPollMinutes', 'autoVodMaxAgeHours',
|
||||||
'autoCleanupEnabledToggle', 'autoCleanupDays', 'autoCleanupTarget', 'autoCleanupAction', 'streamlinkQuality',
|
'autoCleanupEnabledToggle', 'autoCleanupDays', 'autoCleanupTarget', 'autoCleanupAction', 'streamlinkQuality',
|
||||||
'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate',
|
'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate'
|
||||||
'downloadThrottleMiBps', 'downloadWindows', 'downloadPolicyValidation'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function createInput(value = '', checked = false): Input {
|
function createInput(value = '', checked = false): Input {
|
||||||
@@ -26,52 +25,6 @@ function createInput(value = '', checked = false): Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('renderer settings autosave orchestration', () => {
|
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()]));
|
|
||||||
inputs.get('downloadThrottleMiBps')!.value = '1';
|
|
||||||
inputs.get('downloadWindows')!.value = '22:00-06:00';
|
|
||||||
const saveConfigCalls: Array<Record<string, unknown>> = [];
|
|
||||||
const window = {
|
|
||||||
api: {
|
|
||||||
setClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
|
|
||||||
clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
|
|
||||||
setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
|
|
||||||
clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
|
|
||||||
saveConfig(payload: Record<string, unknown>) {
|
|
||||||
saveConfigCalls.push(payload);
|
|
||||||
return Promise.resolve(payload);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const sandbox = {
|
|
||||||
window,
|
|
||||||
config: { download_policy: { throttle: { maxBytesPerSecond: 1_048_576 }, windows: [{ start: '22:00', end: '06:00' }] } },
|
|
||||||
UI_TEXT: { status: {}, static: {}, streamers: {} },
|
|
||||||
byId: (id: string) => inputs.get(id) ?? createInput(),
|
|
||||||
collectUnknownTemplatePlaceholders: () => [],
|
|
||||||
document: { hidden: false, querySelector: () => null, getElementById: () => null },
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
console,
|
|
||||||
};
|
|
||||||
const context = vm.createContext(sandbox);
|
|
||||||
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8');
|
|
||||||
const compiled = ts.transpileModule(source, {
|
|
||||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None },
|
|
||||||
}).outputText;
|
|
||||||
vm.runInContext(compiled, context);
|
|
||||||
|
|
||||||
vm.runInContext('lastPersistedSettingsFingerprint = getSettingsFingerprint(collectAutoSavePayload())', context);
|
|
||||||
inputs.get('downloadThrottleMiBps')!.value = '1.5';
|
|
||||||
await (vm.runInContext('flushSettingsAutoSave(false)', context) as Promise<void>);
|
|
||||||
|
|
||||||
expect(saveConfigCalls).toHaveLength(1);
|
|
||||||
expect(saveConfigCalls[0].download_policy).toEqual({
|
|
||||||
throttle: { maxBytesPerSecond: 1_572_864 },
|
|
||||||
windows: [{ start: '22:00', end: '06:00' }]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists a newer secret after an earlier asynchronous save settles', async () => {
|
it('persists a newer secret after an earlier asynchronous save settles', async () => {
|
||||||
const inputs = new Map(inputIds.map((id) => [id, createInput()]));
|
const inputs = new Map(inputIds.map((id) => [id, createInput()]));
|
||||||
inputs.get('clientSecret')!.value = 'A';
|
inputs.get('clientSecret')!.value = 'A';
|
||||||
|
|||||||
@@ -859,8 +859,6 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
effective.auto_cleanup_action ?? 'archive',
|
effective.auto_cleanup_action ?? 'archive',
|
||||||
effective.streamlink_quality ?? 'best',
|
effective.streamlink_quality ?? 'best',
|
||||||
effective.metadata_cache_minutes ?? 10,
|
effective.metadata_cache_minutes ?? 10,
|
||||||
effective.download_policy?.throttle?.maxBytesPerSecond ?? null,
|
|
||||||
effective.download_policy?.windows ?? [],
|
|
||||||
effective.filename_template_vod ?? '{title}.mp4',
|
effective.filename_template_vod ?? '{title}.mp4',
|
||||||
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
|
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
|
||||||
effective.filename_template_clip ?? '{date}_{part}.mp4'
|
effective.filename_template_clip ?? '{date}_{part}.mp4'
|
||||||
|
|||||||
Reference in New Issue
Block a user