diff --git a/src/main.ts b/src/main.ts index 5e9a004..e0f990a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,6 @@ import * as fs from 'fs'; import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; import { connect as tlsConnect, TLSSocket } from 'node:tls'; import { pathToFileURL } from 'node:url'; -import type { Transform } from 'node:stream'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; 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 { watchRendererChanges } from './main/dev-reload'; 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 { PartialDownloadRegistry } from './main/domain/partial-download'; import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry'; @@ -49,6 +48,7 @@ import { CUTTER_EXPORT_PROFILES, getCutterExportProfile, parseCutterHardwareEncoders, + probeCutterHardwareEncoders, type CutterExportEncoder, type CutterExportProfile, type CutterHardwareEncoder, @@ -419,11 +419,6 @@ function getStreamlinkStreamArg(): string { 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 { // downloaded_vod_ids is bounded so a long-running app doesn't accumulate // an unbounded list across years of downloads. Latest entries kept. @@ -815,7 +810,6 @@ const activeDownloads = new Map(); const cancelledItemIds = new Set(); const queueProcessRegistry = new QueueProcessRegistry(); const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry); -const downloadThrottleBudget = createTokenBucketBudget(null); let downloadPolicyWakeTimer: NodeJS.Timeout | null = null; let lastDownloadPolicyStatusFingerprint = ''; @@ -2970,30 +2964,39 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set { + 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 { if (cutterHardwareEncoderProbe) return await cutterHardwareEncoderProbe; cutterHardwareEncoderProbe = (async (): Promise => { if (!await ensureFfmpegInstalled()) return []; - return await new Promise((resolve) => { - const proc = spawn(getFFmpegPath(), ['-hide_banner', '-encoders'], { windowsHide: true }); - currentCutterProbeProcesses.add(proc); - proc.stderr?.resume(); - let output = ''; - 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([])); + const inventory = await runCutterFfmpegProbe(['-hide_banner', '-encoders'], true); + if (!inventory.success) return []; + return await probeCutterHardwareEncoders(parseCutterHardwareEncoders(inventory.output), async (_encoder, args) => { + const probe = await runCutterFfmpegProbe([...args], false); + return probe.success; }); })(); try { @@ -4046,10 +4049,11 @@ function downloadVODPart( resolve({ success: false, error: tBackend('unknownDownloadError') }); return; } + const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond; const output = createPausableOutput( proc.stdout, outputStream, - createDownloadThrottleTransform(), + maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined, ); const outputFinished = output.finished.then(() => null, (error) => error); const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', { @@ -7521,7 +7525,6 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability } const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig }); 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')) { scheduleQueueProcessing(); } else { @@ -8172,10 +8175,11 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = resolve({ success: false, error: tBackend('unknownDownloadError') }); return; } + const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond; const output = createPausableOutput( proc.stdout, fs.createWriteStream(partialFilename, { flags: 'w' }), - createDownloadThrottleTransform(), + maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined, ); const outputFinished = output.finished.then(() => null, (error) => error); diff --git a/src/main/domain/cutter-export.test.ts b/src/main/domain/cutter-export.test.ts index acafd71..b323018 100644 --- a/src/main/domain/cutter-export.test.ts +++ b/src/main/domain/cutter-export.test.ts @@ -1,5 +1,5 @@ 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', () => { 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', () => { 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', '-']), + }); + }); }); diff --git a/src/main/domain/cutter-export.ts b/src/main/domain/cutter-export.ts index 50ec9c9..96d69b9 100644 --- a/src/main/domain/cutter-export.ts +++ b/src/main/domain/cutter-export.ts @@ -183,6 +183,21 @@ export function parseCutterHardwareEncoders(ffmpegEncodersOutput: string): Cutte 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, +): Promise { + 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 { return getProfileDefinition(profile); } diff --git a/src/main/domain/download-policy-integration.test.ts b/src/main/domain/download-policy-integration.test.ts index eb11ef7..1c4300d 100644 --- a/src/main/domain/download-policy-integration.test.ts +++ b/src/main/domain/download-policy-integration.test.ts @@ -52,16 +52,8 @@ describe('download policy integration contract', () => { const end = source.indexOf('const outputFinished = output.finished', start); 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).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); - }); }); diff --git a/src/main/domain/token-bucket-transform.test.ts b/src/main/domain/token-bucket-transform.test.ts index b350087..38d3430 100644 --- a/src/main/domain/token-bucket-transform.test.ts +++ b/src/main/domain/token-bucket-transform.test.ts @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream'; 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 { private nextTimerId = 0; @@ -75,39 +75,4 @@ describe('app-side token bucket transform', () => { expect(clock.timerCount).toBe(0); 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'); - }); }); diff --git a/src/main/domain/token-bucket-transform.ts b/src/main/domain/token-bucket-transform.ts index 3cb73c5..201687f 100644 --- a/src/main/domain/token-bucket-transform.ts +++ b/src/main/domain/token-bucket-transform.ts @@ -6,147 +6,53 @@ export interface TokenBucketClock { clearTimeout(handle: ReturnType): 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 = { now: () => Date.now(), setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), clearTimeout: (handle) => clearTimeout(handle), }; -function assertRate(maxBytesPerSecond: number): void { - if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer'); -} - -class SharedTokenBucketBudget implements TokenBucketBudget { +class TokenBucketTransform extends Transform { private availableBytes: number; private lastRefillAt: number; private timer: ReturnType | null = null; - private draining = false; - private readonly reservations: TokenBucketReservation[] = []; - constructor(private maxBytesPerSecond: number | null, 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) { + constructor(private readonly maxBytesPerSecond: number, private readonly clock: TokenBucketClock) { super(); + this.availableBytes = maxBytesPerSecond; + this.lastRefillAt = clock.now(); } override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { const output = Buffer.from(chunk); - this.cancelReservation = this.budget.reserve(output.length, () => { - this.cancelReservation = null; + const capacity = Math.max(this.maxBytesPerSecond, output.length); + const release = (): void => { + this.timer = null; if (this.destroyed) return; - this.push(output); - callback(); - }); + 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; + 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 { - this.cancelReservation?.(); - this.cancelReservation = null; + if (this.timer) this.clock.clearTimeout(this.timer); + this.timer = null; callback(error); } } -export function createTokenBucketBudget(maxBytesPerSecond: number | null, clock: TokenBucketClock = systemClock): TokenBucketBudget { - return new SharedTokenBucketBudget(maxBytesPerSecond, clock); -} - -export function createTokenBucketTransform( - maxBytesPerSecond: number, - clock: TokenBucketClock = systemClock, - budget: TokenBucketBudget = createTokenBucketBudget(maxBytesPerSecond, clock), -): Transform { - assertRate(maxBytesPerSecond); - return new TokenBucketTransform(budget); +export function createTokenBucketTransform(maxBytesPerSecond: number, clock: TokenBucketClock = systemClock): Transform { + if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer'); + return new TokenBucketTransform(maxBytesPerSecond, clock); } diff --git a/src/renderer-cutter.production-path.test.ts b/src/renderer-cutter.production-path.test.ts new file mode 100644 index 0000000..6b5062a --- /dev/null +++ b/src/renderer-cutter.production-path.test.ts @@ -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, expose: string): Record 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 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>(); + const element = (): Record => ({ + 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')); + }); +}); diff --git a/src/renderer-cutter.ts b/src/renderer-cutter.ts index cce2be4..407bf57 100644 --- a/src/renderer-cutter.ts +++ b/src/renderer-cutter.ts @@ -67,6 +67,7 @@ let cutterAudioStreamIndex = 0; let cutterPendingProject: CutterProject | null = null; let cutterAutosaveTimer: number | null = null; let cutterExportOptions: CutterExportOptions | null = null; +let cutterRecoveryDecisionPending = false; const cutterMaximumCuts = 64; const cutterFrameTolerance = 1e-8; @@ -174,7 +175,7 @@ function scheduleCutterAutosave(): void { const file = cutterFile; cutterAutosaveTimer = window.setTimeout(() => { cutterAutosaveTimer = null; - if (!file || cutterFile !== file) return; + if (!file || cutterFile !== file || cutterRecoveryDecisionPending) return; void persistCutterProject(false); }, 500); } @@ -279,6 +280,7 @@ async function recoverCutterProject(): Promise { showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn'); return; } + cutterRecoveryDecisionPending = false; renderCutterProjectRecovery(null); showAppToast('Projekt wiederhergestellt', 'info'); } @@ -286,21 +288,24 @@ async function recoverCutterProject(): Promise { async function discardCutterProject(): Promise { if (!cutterFile) return; try { await window.api.discardCutterProject(cutterFile.token); } catch { } + cutterRecoveryDecisionPending = false; renderCutterProjectRecovery(null); } async function saveCutterProject(): Promise { + cutterRecoveryDecisionPending = false; await persistCutterProject(true); } async function openCutterProject(): Promise { - if (!cutterFile || !await persistCutterProject(false)) return; + if (!cutterFile) return; let project: CutterProject | null = null; try { project = await window.api.openCutterProject(cutterFile.token); } catch { } if (!project || !applyCutterProject(project)) { showAppToast('Kein passendes Projekt gefunden', 'warn'); return; } + cutterRecoveryDecisionPending = false; renderCutterProjectRecovery(null); showAppToast('Projekt geöffnet', 'info'); } @@ -1068,6 +1073,7 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise cutterExportProfile = 'balanced'; cutterExportEncoder = 'software'; cutterAudioStreamIndex = media.info.audioStreams[0]?.index ?? 0; + cutterRecoveryDecisionPending = true; renderCutterProjectRecovery(null); updateCutterAudioStreams(); cutterZoom = getInitialCutterZoom(media.info.duration); @@ -1078,7 +1084,6 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise byId('cutterInfo').classList.add('shown'); byId('timelineContainer').classList.add('shown'); if (previousPreviewRect) animateCutterWorkspaceReveal(previousPreviewRect); - byId('btnCut').disabled = false; byId('infoDuration').textContent = formatCutterTimecode(media.info.duration); byId('infoResolution').textContent = `${media.info.width}×${media.info.height}`; byId('infoFps').textContent = media.info.fps.toFixed(media.info.fps % 1 === 0 ? 0 : 2); @@ -1093,16 +1098,18 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise video.load(); byId('cutterPreview').classList.remove('playing', 'buffering'); updateCutterPlayUi(); - setCutterControlsEnabled(true); updateCutterZoom(cutterZoom); renderCutterEditor(); updateCutterPlayhead(0); void (async () => { - await loadCutterExportOptions(file, generation); - if (generation !== cutterLoadGeneration || cutterFile !== file) return; let project: CutterProject | null = null; 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('btnCut').disabled = false; + void loadCutterExportOptions(file, generation); })(); void requestCutterWaveform(file, media.jobId, generation); void requestCutterAssets(); @@ -1145,7 +1152,7 @@ function confirmCutterReplacement(file: FileCapabilityReference): Promise { if (!file || isCutting) return; if (!await confirmCutterReplacement(file)) return; - if (cutterEditorState && !await persistCutterProject(false)) { + if (cutterEditorState && !cutterRecoveryDecisionPending && !await persistCutterProject(false)) { showAppToast('Projekt konnte nicht gespeichert werden', 'warn'); return; } diff --git a/src/renderer-settings-autosave.test.ts b/src/renderer-settings-autosave.test.ts index 76e5abb..e287d8d 100644 --- a/src/renderer-settings-autosave.test.ts +++ b/src/renderer-settings-autosave.test.ts @@ -17,8 +17,7 @@ const inputIds = [ 'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', 'discordNotifyVodAutoQueuedToggle', 'autoVodPollMinutes', 'autoVodMaxAgeHours', 'autoCleanupEnabledToggle', 'autoCleanupDays', 'autoCleanupTarget', 'autoCleanupAction', 'streamlinkQuality', - 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate', - 'downloadThrottleMiBps', 'downloadWindows', 'downloadPolicyValidation' + 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate' ]; function createInput(value = '', checked = false): Input { @@ -26,52 +25,6 @@ function createInput(value = '', checked = false): Input { } 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> = []; - 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) { - 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); - - 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 () => { const inputs = new Map(inputIds.map((id) => [id, createInput()])); inputs.get('clientSecret')!.value = 'A'; diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index c8162c3..0b303bc 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -859,8 +859,6 @@ function getSettingsFingerprint(payload: Partial): string { effective.auto_cleanup_action ?? 'archive', effective.streamlink_quality ?? 'best', effective.metadata_cache_minutes ?? 10, - effective.download_policy?.throttle?.maxBytesPerSecond ?? null, - effective.download_policy?.windows ?? [], effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', effective.filename_template_clip ?? '{date}_{part}.mp4'