mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
* test(e2e): explain black canvases in the packaged frame-copy smoke When the smoke's canvas stays black, print and attach the session snapshot (with mpv's drop counter), every slot of the helper's shared-memory frame rings read from /dev/shm, and the session's verbose mpv log (log-file). Together they separate these cases: the helper never published a frame, mpv rendered black, or the preload pump did not draw a real frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): stop random black frames on software GL in frame-copy mpv's LUT scalers (the default lanczos/spline family) build their weight texture from an uninitialized talloc_array: mp_compute_lut fills only kernel->size of every stride floats, so each row's padding is heap garbage (reinit_scaler in video/out/gpu/video.c, mpv 0.41 and master). The LUT is an rgba16f texture sampled with linear filtering. When the padding holds NaN, or a value large enough to become Inf as half-float, Mesa's llvmpipe carries it through the zero-weight neighbour texel, the weights become NaN and the frame clamps to pure black. Whether it happens depends on heap contents, so the packaged smoke's paused frame was black in most attempts on the CI runner. Evidence from CI (8 repeats each, no retries): baseline 2/8 passed; LUT-free bilinear scalers 8/8; gpu-dumb-mode 8/8; LP_NUM_THREADS=1 6/8. A synchronous glReadPixels right after mpv_render_context_render() showed the black frame already in the helper's FBO. The readback, the shared-memory ring and the preload pump were correct. On a CPU rasterizer the helper now selects mpv's LUT-free bilinear scalers and disables sigmoid upscaling. Software GL cannot afford the LUT scalers anyway. A session option for the same key still wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): report software scaler results and scope smoke ring diagnostics The software-renderer fallback now reports which scaler options mpv accepted and which it rejected (with mpv's error), instead of always printing success. The smoke's black-canvas diagnostics read only the failed session's rings (<sessionId>-g<N>), not every impv-fc ring in /dev/shm. Addresses Greptile review feedback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
563 lines
22 KiB
TypeScript
563 lines
22 KiB
TypeScript
import {
|
|
expect,
|
|
launchPackagedElectronApp,
|
|
resolvePackagedLinuxExecutable,
|
|
test,
|
|
type LaunchedElectronApp,
|
|
} from './electron-test-fixtures';
|
|
import { readFileSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import {
|
|
assertNativeFallbackPrerequisites,
|
|
cleanupPackagedFrameCopySmoke,
|
|
closeAndWaitForExit,
|
|
createLocalMediaServer,
|
|
createWavFixture,
|
|
getEmbeddedMpvSupport,
|
|
getLatestSession,
|
|
installEmbeddedMpvSessionCapture,
|
|
installFrameCanvasAndSessionCapture,
|
|
isMeaningfulNativePlaybackSnapshot,
|
|
type LocalMediaServer,
|
|
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
|
import { expectRenderedFrame } from './embedded-mpv-frame-copy-packaged-diagnostics';
|
|
import {
|
|
createDisposablePackagedLinuxApp,
|
|
createPackagedEntryGuard,
|
|
readPackagedRuntimeIdentity,
|
|
type DisposablePackagedLinuxApp,
|
|
type PackagedEntryGuard,
|
|
} from './embedded-mpv-frame-copy-packaged-filesystem';
|
|
|
|
const PACKAGED_FRAME_COPY_REQUIRED_ENV =
|
|
'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY';
|
|
const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
|
|
const packagedExecutable = resolvePackagedLinuxExecutable();
|
|
const packagedFrameCopyRequired = isTruthy(
|
|
process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV]
|
|
);
|
|
|
|
function isTruthy(value: string | undefined): boolean {
|
|
return ['1', 'true', 'yes', 'on'].includes(
|
|
(value ?? '').trim().toLowerCase()
|
|
);
|
|
}
|
|
|
|
// This smoke drives the public preload API directly so it exercises the real
|
|
// packaged main process, helper, frame reader, WebGL pump, and pause controls
|
|
// without coupling runtime validation to playlist import/settings UI state.
|
|
test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
|
test.skip(
|
|
process.platform !== 'linux',
|
|
'The packaged frame-copy runtime is Linux-only.'
|
|
);
|
|
test.skip(
|
|
!packagedExecutable && !packagedFrameCopyRequired,
|
|
`Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.`
|
|
);
|
|
|
|
test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({
|
|
dataDir,
|
|
}) => {
|
|
expect(
|
|
process.arch,
|
|
'The official Linux frame-copy runtime is x64-only.'
|
|
).toBe('x64');
|
|
expect(
|
|
packagedExecutable,
|
|
`The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.`
|
|
).toBeTruthy();
|
|
|
|
const sourceExecutablePath = packagedExecutable as string;
|
|
assertNativeFallbackPrerequisites();
|
|
let packageClone: DisposablePackagedLinuxApp | undefined;
|
|
let hiddenRuntimeEntry: PackagedEntryGuard | undefined;
|
|
let media: LocalMediaServer | undefined;
|
|
let frameCopyApp: LaunchedElectronApp | undefined;
|
|
let fallbackApp: LaunchedElectronApp | undefined;
|
|
|
|
try {
|
|
packageClone =
|
|
createDisposablePackagedLinuxApp(sourceExecutablePath);
|
|
const executablePath = packageClone.executablePath;
|
|
const nativeDir = packageClone.nativeDir;
|
|
const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir);
|
|
hiddenRuntimeEntry = createPackagedEntryGuard(
|
|
join(nativeDir, 'lib', runtimeIdentity.libmpvSoname),
|
|
{
|
|
expectedKind: 'regular-file',
|
|
hiddenDirectory: packageClone.temporaryRoot,
|
|
}
|
|
);
|
|
const mediaServer = await createLocalMediaServer();
|
|
media = mediaServer;
|
|
|
|
expect(runtimeIdentity).toMatchObject({
|
|
arch: 'x64',
|
|
platform: 'linux',
|
|
runtimeMode: 'bundled',
|
|
});
|
|
expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile);
|
|
|
|
const launchedFrameCopyApp = await launchPackagedElectronApp(
|
|
executablePath,
|
|
dataDir,
|
|
{
|
|
env: {
|
|
[FRAME_COPY_OPT_IN_ENV]: '1',
|
|
},
|
|
}
|
|
);
|
|
frameCopyApp = launchedFrameCopyApp;
|
|
|
|
await expect
|
|
.poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp))
|
|
.toMatchObject({
|
|
engine: 'frame-copy',
|
|
frameCopyAvailable: true,
|
|
platform: 'linux',
|
|
supported: true,
|
|
});
|
|
|
|
await installFrameCanvasAndSessionCapture(launchedFrameCopyApp);
|
|
const mpvLogPath = join(dataDir, 'mpv-frame-copy.log');
|
|
const created = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (logFile) => {
|
|
// Decode audio without requiring a sound device on CI;
|
|
// keep mpv's verbose log for failure diagnostics.
|
|
await window.electron.updateSettings({
|
|
embeddedMpvExtraOptions: `ao=null\nlog-file=${logFile}`,
|
|
});
|
|
return window.electron.createEmbeddedMpvSession(
|
|
{ x: 0, y: 0, width: 320, height: 180 },
|
|
'Packaged frame-copy smoke',
|
|
0
|
|
);
|
|
},
|
|
mpvLogPath
|
|
);
|
|
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, streamUrl }) => {
|
|
await window.electron.setEmbeddedMpvPaused(sessionId, true);
|
|
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
|
streamUrl,
|
|
title: 'Generated Y4M fixture',
|
|
isLive: false,
|
|
});
|
|
},
|
|
{ sessionId: created.id, streamUrl: mediaServer.url }
|
|
);
|
|
|
|
await expect
|
|
.poll(
|
|
() => getLatestSession(launchedFrameCopyApp, created.id),
|
|
{
|
|
timeout: 15000,
|
|
}
|
|
)
|
|
.toMatchObject({
|
|
status: 'paused',
|
|
streamUrl: mediaServer.url,
|
|
videoHeight: 36,
|
|
videoWidth: 64,
|
|
});
|
|
|
|
const attached = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (sessionId) => {
|
|
return window.electron.attachEmbeddedMpvFrameView?.(
|
|
sessionId
|
|
);
|
|
},
|
|
created.id
|
|
);
|
|
expect(attached).toBe(true);
|
|
await expect(
|
|
launchedFrameCopyApp.mainWindow.getByTestId(
|
|
'packaged-embedded-mpv-frame'
|
|
)
|
|
).toHaveAttribute('width', '320');
|
|
await expect(
|
|
launchedFrameCopyApp.mainWindow.getByTestId(
|
|
'packaged-embedded-mpv-frame'
|
|
)
|
|
).toHaveAttribute('height', '180');
|
|
await expectRenderedFrame(
|
|
launchedFrameCopyApp,
|
|
created.id,
|
|
15000,
|
|
mpvLogPath
|
|
);
|
|
|
|
// Relative seek steps (arrow keys, ±10 s buttons) must reach mpv
|
|
// as `seek <delta> relative+exact` through the real preload →
|
|
// main-process → helper `seek-by` path, and a burst issued without
|
|
// waiting for snapshots has to accumulate. Absolute targets
|
|
// computed from the stale renderer snapshot collapsed such bursts
|
|
// onto one target (about one second of progress per press).
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (sessionId) => {
|
|
const seekBy = window.electron.seekEmbeddedMpvBy;
|
|
if (!seekBy) {
|
|
throw new Error(
|
|
'seekEmbeddedMpvBy is missing from the preload bridge'
|
|
);
|
|
}
|
|
await Promise.all([
|
|
seekBy(sessionId, 2),
|
|
seekBy(sessionId, 2),
|
|
seekBy(sessionId, 2),
|
|
]);
|
|
},
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.positionSeconds,
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBe(6);
|
|
// Stepping back past the start clamps at zero instead of failing.
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.seekEmbeddedMpvBy?.(sessionId, -60),
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.positionSeconds,
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBe(0);
|
|
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.setEmbeddedMpvPaused(sessionId, false),
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.status,
|
|
{ timeout: 10000 }
|
|
)
|
|
.toBe('playing');
|
|
|
|
// Advanced subtitle support (#1408): exercise the real preload
|
|
// IPC, main-process validation, and the helper's `sub-add`
|
|
// protocol command against the packaged runtime. The native file
|
|
// dialog cannot be automated, so the path-based IPC is driven
|
|
// directly with a fixture file; mpv parses and auto-selects it,
|
|
// and the helper's track-list observation must report it back.
|
|
const subtitlePath = join(dataDir, 'packaged-smoke.srt');
|
|
writeFileSync(
|
|
subtitlePath,
|
|
'1\n00:00:00,000 --> 00:00:10,000\nPackaged subtitle smoke\n'
|
|
);
|
|
const withSubtitle = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, path }) =>
|
|
window.electron.addEmbeddedMpvSubtitle?.(sessionId, path),
|
|
{ sessionId: created.id, path: subtitlePath }
|
|
);
|
|
expect(withSubtitle?.id).toBe(created.id);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const session = await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
);
|
|
return session?.subtitleTracks?.length ?? 0;
|
|
},
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
// Delay and style ride the same session-guarded IPC; mpv does not
|
|
// echo the values in the snapshot, so a session reply for the live
|
|
// session (instead of a rejection) is the observable contract.
|
|
const delayReply = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.setEmbeddedMpvSubtitleDelay?.(
|
|
sessionId,
|
|
1.5
|
|
),
|
|
created.id
|
|
);
|
|
expect(delayReply?.id).toBe(created.id);
|
|
const styleReply = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.setEmbeddedMpvSubtitleStyle?.(sessionId, {
|
|
sizePercent: 150,
|
|
color: '#ffe94f',
|
|
}),
|
|
created.id
|
|
);
|
|
expect(styleReply?.id).toBe(created.id);
|
|
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.setEmbeddedMpvPaused(sessionId, true),
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.status,
|
|
{ timeout: 10000 }
|
|
)
|
|
.toBe('paused');
|
|
// These values must originate in real libmpv property observations,
|
|
// survive helper JSON/preload mapping, and change with the source.
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.stats
|
|
)
|
|
.toMatchObject({
|
|
videoCodec: expect.any(String),
|
|
container: expect.any(String),
|
|
droppedFrames: expect.any(Number),
|
|
});
|
|
const originalStats = (await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
))!.stats!;
|
|
const alternateMedia = await createLocalMediaServer({
|
|
body: readFileSync(
|
|
join(
|
|
__dirname,
|
|
'../../web-e2e/src/fixtures/playback/episode.webm'
|
|
)
|
|
),
|
|
resourcePath: '/stream-stats-switch.webm',
|
|
contentType: 'video/webm',
|
|
});
|
|
media = {
|
|
url: mediaServer.url,
|
|
close: async () => {
|
|
await Promise.all([
|
|
mediaServer.close(),
|
|
alternateMedia.close(),
|
|
]);
|
|
},
|
|
};
|
|
const audioMedia = await createLocalMediaServer({
|
|
body: createWavFixture(),
|
|
resourcePath: '/stream-stats-audio.wav',
|
|
contentType: 'audio/wav',
|
|
});
|
|
const videoMedia = media;
|
|
media = {
|
|
url: videoMedia.url,
|
|
close: async () => {
|
|
await Promise.all([videoMedia.close(), audioMedia.close()]);
|
|
},
|
|
};
|
|
// mpv uses its built-in demuxer name "mkv" for WebM.
|
|
for (const [streamUrl, videoCodec, container] of [
|
|
[alternateMedia.url, 'vp8', 'mkv'],
|
|
[
|
|
mediaServer.url,
|
|
originalStats.videoCodec,
|
|
originalStats.container,
|
|
],
|
|
] as const) {
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, streamUrl }) => {
|
|
await window.electron.loadEmbeddedMpvPlayback(
|
|
sessionId,
|
|
{
|
|
streamUrl,
|
|
title: 'Stream stats source switch',
|
|
isLive: false,
|
|
}
|
|
);
|
|
},
|
|
{ sessionId: created.id, streamUrl }
|
|
);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
getLatestSession(launchedFrameCopyApp, created.id),
|
|
{ timeout: 15000 }
|
|
)
|
|
.toMatchObject({
|
|
streamUrl,
|
|
stats: { videoCodec, container },
|
|
});
|
|
}
|
|
|
|
// Audio-only replacements must not retain the previous video size.
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, streamUrl }) => {
|
|
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
|
streamUrl,
|
|
title: 'Audio-only statistics',
|
|
isLive: false,
|
|
});
|
|
},
|
|
{ sessionId: created.id, streamUrl: audioMedia.url }
|
|
);
|
|
await expect
|
|
.poll(
|
|
() => getLatestSession(launchedFrameCopyApp, created.id),
|
|
{ timeout: 15000 }
|
|
)
|
|
.toMatchObject({
|
|
streamUrl: audioMedia.url,
|
|
videoWidth: 0,
|
|
videoHeight: 0,
|
|
stats: {
|
|
audioCodec: 'pcm_s16le',
|
|
audioChannels: expect.any(String),
|
|
audioSampleRateHz: 8000,
|
|
},
|
|
});
|
|
// Revoking the only audio track emits MPV_FORMAT_NONE without a
|
|
// START_FILE reset. Previously these values stayed cached forever.
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (sessionId) => {
|
|
await window.electron.setEmbeddedMpvAudioTrack(
|
|
sessionId,
|
|
-1
|
|
);
|
|
},
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(async () => {
|
|
const stats = (
|
|
await getLatestSession(launchedFrameCopyApp, created.id)
|
|
)?.stats;
|
|
return {
|
|
codec: stats?.audioCodec ?? null,
|
|
channels: stats?.audioChannels ?? null,
|
|
sampleRate: stats?.audioSampleRateHz ?? null,
|
|
};
|
|
})
|
|
.toEqual({ codec: null, channels: null, sampleRate: null });
|
|
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (sessionId) => {
|
|
window.electron.detachEmbeddedMpvFrameView?.();
|
|
await window.electron.disposeEmbeddedMpvSession(sessionId);
|
|
window.__packagedEmbeddedMpvUnsubscribe?.();
|
|
},
|
|
created.id
|
|
);
|
|
|
|
await closeAndWaitForExit(launchedFrameCopyApp);
|
|
frameCopyApp = undefined;
|
|
|
|
hiddenRuntimeEntry.hide();
|
|
expect(readPackagedRuntimeIdentity(nativeDir)).toEqual(
|
|
runtimeIdentity
|
|
);
|
|
|
|
const launchedFallbackApp = await launchPackagedElectronApp(
|
|
executablePath,
|
|
dataDir,
|
|
{
|
|
env: {
|
|
[FRAME_COPY_OPT_IN_ENV]: '1',
|
|
},
|
|
}
|
|
);
|
|
fallbackApp = launchedFallbackApp;
|
|
const fallbackSupport =
|
|
await getEmbeddedMpvSupport(launchedFallbackApp);
|
|
|
|
expect(fallbackSupport).toMatchObject({
|
|
engine: 'native',
|
|
frameCopyAvailable: false,
|
|
frameCopyUnavailableReason: 'runtime-library-missing',
|
|
platform: 'linux',
|
|
supported: true,
|
|
});
|
|
|
|
await installEmbeddedMpvSessionCapture(launchedFallbackApp);
|
|
const nativeSession = await launchedFallbackApp.mainWindow.evaluate(
|
|
async () => {
|
|
return window.electron.createEmbeddedMpvSession(
|
|
{ x: 0, y: 0, width: 320, height: 180 },
|
|
'Native-view fallback smoke',
|
|
0
|
|
);
|
|
}
|
|
);
|
|
expect(nativeSession.id).toMatch(/^embedded-mpv-/);
|
|
await launchedFallbackApp.mainWindow.evaluate(
|
|
async ({ sessionId, streamUrl }) => {
|
|
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
|
streamUrl,
|
|
title: 'Native-view generated Y4M fixture',
|
|
isLive: false,
|
|
});
|
|
},
|
|
{ sessionId: nativeSession.id, streamUrl: mediaServer.url }
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
isMeaningfulNativePlaybackSnapshot(
|
|
await getLatestSession(
|
|
launchedFallbackApp,
|
|
nativeSession.id
|
|
),
|
|
mediaServer.url
|
|
),
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBe(true);
|
|
expect(
|
|
launchedFallbackApp.electronApp.process().exitCode
|
|
).toBeNull();
|
|
expect(
|
|
launchedFallbackApp.electronApp.process().signalCode
|
|
).toBeNull();
|
|
await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => {
|
|
await window.electron.disposeEmbeddedMpvSession(sessionId);
|
|
window.__packagedEmbeddedMpvUnsubscribe?.();
|
|
}, nativeSession.id);
|
|
await expect
|
|
.poll(() => launchedFallbackApp.mainWindow.title())
|
|
.toContain('IPTVnator');
|
|
} finally {
|
|
await cleanupPackagedFrameCopySmoke({
|
|
apps: [frameCopyApp, fallbackApp],
|
|
hiddenRuntimeEntry,
|
|
media,
|
|
packageClone,
|
|
});
|
|
}
|
|
});
|
|
});
|