From 3b4af8caf0f429a4c26f7e4abd24d42e440e2878 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 19:21:10 +0000 Subject: [PATCH] video gen: make display-sleep opt-in and settable per render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPU-watchdog mitigation (sleeping the display during local MLX video renders) defaulted to ON and was settings-only. A render is a short, attended action, so sleeping the screen unasked reads as a crash — flip the default to OFF, and add a visible per-render checkbox on the Video Gen form (in addition to the existing Settings default) so a user who hits the watchdog crash can opt in without changing the install default. --- .../src/components/settings/ImageGenTab.jsx | 8 +- .../components/settings/ImageGenTab.test.jsx | 2 +- client/src/hooks/useVideoGenForm.js | 9 +- client/src/lib/videoGenSubmission.js | 6 ++ .../src/pages/VideoGen.displaySleep.test.jsx | 85 +++++++++++++++++++ client/src/pages/VideoGen.jsx | 58 ++++++++++--- server/lib/validation.js | 7 +- server/routes/videoGen.js | 12 ++- server/routes/videoGen.test.js | 4 +- server/services/displayPower.js | 28 ++++-- server/services/videoGen/displayPower.js | 31 +++++++ server/services/videoGen/displayPower.test.js | 71 ++++++++++++++++ server/services/videoGen/generateVideo.js | 10 ++- server/services/videoGen/spawnWatch.js | 10 +-- server/services/videoGen/submitJob.js | 5 ++ 15 files changed, 309 insertions(+), 37 deletions(-) create mode 100644 client/src/pages/VideoGen.displaySleep.test.jsx create mode 100644 server/services/videoGen/displayPower.js create mode 100644 server/services/videoGen/displayPower.test.js diff --git a/client/src/components/settings/ImageGenTab.jsx b/client/src/components/settings/ImageGenTab.jsx index 96133e244f..ef622a2bd7 100644 --- a/client/src/components/settings/ImageGenTab.jsx +++ b/client/src/components/settings/ImageGenTab.jsx @@ -111,7 +111,7 @@ export function ImageGenTab() { // never rendered, only round-tripped at save time so sibling keys // (defaultModelId) survive the settings PUT's wholesale slice replace. const [videoGenMode, setVideoGenMode] = useState(''); - const [videoGenDisplaySleep, setVideoGenDisplaySleep] = useState(true); + const [videoGenDisplaySleep, setVideoGenDisplaySleep] = useState(false); // fal.ai queue REST API key (#6213) — usability-gated on this being set // (settings, or the FAL_KEY env var server-side). No enabled toggle: the // key's presence IS the opt-in, same shape as loras.js's Civitai key. @@ -182,7 +182,7 @@ export function ImageGenTab() { denoiseByMode: { external: false, local: false, codex: false, grok: false, agy: false }, renderDefaultsJson: '{}', videoGenMode: '', - videoGenDisplaySleep: true, + videoGenDisplaySleep: false, falApiKey: '', reactorApiKey: '', }); @@ -256,7 +256,7 @@ export function ImageGenTab() { // ('auto'/blank → '', i.e. no pin) for the select. const vg = (s?.videoGen && typeof s.videoGen === 'object') ? s.videoGen : {}; const vgMode = normalizeRenderPinValue(vg.mode) || ''; - const vgDisplaySleep = vg.displaySleep !== false; + const vgDisplaySleep = vg.displaySleep === true; const vgFalApiKey = vg.fal?.apiKey || ''; const vgReactorApiKey = vg.reactor?.apiKey || ''; const m = ig.mode || IMAGE_GEN_MODE.EXTERNAL; @@ -791,7 +791,7 @@ export function ImageGenTab() { /> Sleep display during local MLX video renders - Keeps the system awake while reducing WindowServer GPU contention on affected Apple silicon. Turn off only when another headless workflow manages display power. + Off by default. Keeps the system awake while putting the screen to sleep, which reduces WindowServer GPU contention on affected Apple silicon. Turn on only if you hit the GPU-watchdog crash during a render — this is also settable per-render on the Video Gen page. { await waitFor(() => expect(updateSettings).toHaveBeenCalled()); const patch = updateSettings.mock.calls[0][0]; expect(patch.videoGen).toEqual({ - mode: 'local', defaultModelId: 'ltx23_distilled_q4', displaySleep: true, fal: {}, reactor: {}, + mode: 'local', defaultModelId: 'ltx23_distilled_q4', displaySleep: false, fal: {}, reactor: {}, }); }); }); diff --git a/client/src/hooks/useVideoGenForm.js b/client/src/hooks/useVideoGenForm.js index 8255ed553f..2799e4b3da 100644 --- a/client/src/hooks/useVideoGenForm.js +++ b/client/src/hooks/useVideoGenForm.js @@ -70,10 +70,16 @@ const editableRemixModel = (models, defaultModelId) => { * `buildGeneratePayload()` emit the text-to-video-only shape the federated * wire accepts — kept here rather than in the page so there stays exactly * one builder for what `server/routes/videoGen.js` validates. + * - `displaySleepEnabled` — the page's effective choice (settings default, + * overridable per render) for whether a local MLX render should sleep the + * display. `buildGeneratePayload()` only attaches it when the SELECTED + * MODEL actually has the mitigation (`currentModel.sleepsDisplayDuringRender`), + * so the page doesn't need its own copy of that gate to build the payload — + * only to decide whether to show the control at all. */ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnabled, falEnabled = false, reactorEnabled = false, - remoteSubmissionFields = null, + remoteSubmissionFields = null, displaySleepEnabled = false, }) { const [searchParams, setSearchParams] = useSearchParams(); const incomingSourceImage = searchParams.get('sourceImageFile'); @@ -1264,6 +1270,7 @@ export function useVideoGenForm({ const submissionState = { isGrok, grokDuration, isFal, falDuration, falModelId, isReactor, reactorClipId, reactorSeconds, reactorSeed, remoteSubmissionFields, + displaySleepEnabled, prompt, negativePrompt, stylePreset, selectedUniverse, width, height, mode, sourceImageFile, sourceImageUpload, numFrames, fps, steps, guidanceScale, seed, diff --git a/client/src/lib/videoGenSubmission.js b/client/src/lib/videoGenSubmission.js index 60c03045e3..50f405eb03 100644 --- a/client/src/lib/videoGenSubmission.js +++ b/client/src/lib/videoGenSubmission.js @@ -36,6 +36,7 @@ export function envelopVideoPrompt(text, { export function buildVideoGenSubmission({ isGrok, grokDuration, isFal, falDuration, falModelId, isReactor, reactorClipId, reactorSeconds, reactorSeed, remoteSubmissionFields, + displaySleepEnabled, prompt, negativePrompt, stylePreset, selectedUniverse, width, height, mode, sourceImageFile, sourceImageUpload, numFrames, fps, steps, guidanceScale, seed, @@ -154,6 +155,11 @@ export function buildVideoGenSubmission({ ? undefined : draftDecode, disableAudio: effectiveDisableAudio ? 'true' : 'false', + // Only meaningful on a runtime the GPU-watchdog mitigation applies to — + // absent otherwise so an unrelated model's render never carries a stale + // choice. The grok/fal/reactor/remote branches above already returned, so + // reaching here already means none of those apply. + ...(currentModel?.sleepsDisplayDuringRender ? { displaySleep: displaySleepEnabled ? 'true' : 'false' } : {}), mode, imageStrength: imageStrength || '', i2vReferenceMode: isDefaultI2vReferenceMode(i2vReferenceMode) ? '' : i2vReferenceMode, diff --git a/client/src/pages/VideoGen.displaySleep.test.jsx b/client/src/pages/VideoGen.displaySleep.test.jsx new file mode 100644 index 0000000000..d9be624406 --- /dev/null +++ b/client/src/pages/VideoGen.displaySleep.test.jsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import { + loadVideoGenPage, + renderVideoGenPage, + resetVideoGenMockState, + state, + videoGenModel, + videoGenModelContext, + videoGenStatus, +} from '../test/videoGenPageMocks.jsx'; + +// `sleepsDisplayDuringRender` (server: runtimeUsesMlx(model.runtime)) is what +// the display-sleep control gates on — set directly rather than through an +// mlx_video runtime, which would also pull in the shared-text-encoder gate +// this suite isn't testing. +const MLX_MODEL = videoGenModel('mlx-one', { sleepsDisplayDuringRender: true }); + +await loadVideoGenPage(); + +// Fills the prompt, submits via Add to queue, and returns the `displaySleep` +// field the render was actually submitted with. +const submitAndGetDisplaySleep = async () => { + fireEvent.change(await screen.findByLabelText('Prompt'), { target: { value: 'a fox watches the rain' } }); + await waitFor(() => expect(screen.getByRole('button', { name: /Add to queue/ })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: /Add to queue/ })); + await waitFor(() => expect(state.generateVideo).toHaveBeenCalled()); + return state.generateVideo.mock.calls[0][0].displaySleep; +}; + +describe('VideoGen per-render display-sleep control', () => { + beforeEach(() => { + resetVideoGenMockState(); + state.getVideoGenModelContext.mockResolvedValue(videoGenModelContext([MLX_MODEL])); + state.modelStatuses = { [MLX_MODEL.id]: { id: MLX_MODEL.id, repo: MLX_MODEL.repo, cached: true, sizeBytes: 100 } }; + state.generateVideo.mockResolvedValue({ jobId: 'job-1' }); + state.attach.mockReturnValue(new Promise(() => {})); + }); + + it('defaults the checkbox to the install setting and sends the choice with the render', async () => { + state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MLX_MODEL], { displaySleepOnRender: true })); + await renderVideoGenPage(); + + const checkbox = await screen.findByLabelText(/Sleep display during this render/i); + await waitFor(() => expect(checkbox).toBeChecked()); + + expect(await submitAndGetDisplaySleep()).toBe('true'); + }); + + it('lets the user opt out for just this render, without changing Settings', async () => { + state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MLX_MODEL], { displaySleepOnRender: true })); + await renderVideoGenPage(); + + const checkbox = await screen.findByLabelText(/Sleep display during this render/i); + await waitFor(() => expect(checkbox).toBeChecked()); + fireEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + + expect(await submitAndGetDisplaySleep()).toBe('false'); + }); + + it('defaults to off, and lets the user opt in for just this render, when the install default is off', async () => { + state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MLX_MODEL], { displaySleepOnRender: false })); + await renderVideoGenPage(); + + const checkbox = await screen.findByLabelText(/Sleep display during this render/i); + await waitFor(() => expect(checkbox).not.toBeChecked()); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + expect(await submitAndGetDisplaySleep()).toBe('true'); + }); + + it('does not offer the control for a runtime the mitigation never applies to', async () => { + const nonMlxModel = videoGenModel('h3-one'); + state.getVideoGenModelContext.mockResolvedValue(videoGenModelContext([nonMlxModel])); + state.modelStatuses = { [nonMlxModel.id]: { id: nonMlxModel.id, repo: nonMlxModel.repo, cached: true, sizeBytes: 100 } }; + state.getVideoGenStatus.mockResolvedValue(videoGenStatus([nonMlxModel], { displaySleepOnRender: false })); + await renderVideoGenPage(); + + await screen.findByLabelText('Prompt'); + expect(screen.queryByLabelText(/Sleep display during this render/i)).toBeNull(); + }); +}); diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx index 05e791215e..89fda02907 100644 --- a/client/src/pages/VideoGen.jsx +++ b/client/src/pages/VideoGen.jsx @@ -196,6 +196,13 @@ export default function VideoGen() { // outright — the clip is rendered there and imported back — so it feeds the // payload builder rather than sitting beside it. const remoteTarget = useFederatedMediaTarget('video'); + // `null` = no per-render override yet — follow the install default + // (settings.videoGen.displaySleep, /status-reported so it can't drift from + // the server's own opt-in read). Set once the user touches the checkbox + // below. Computed before the form hook call because it feeds + // buildGeneratePayload() the same way `remoteSubmissionFields` does. + const [displaySleepOverride, setDisplaySleepOverride] = useState(null); + const displaySleepEnabled = displaySleepOverride ?? !!status?.displaySleepOnRender; // Every field the form submits, plus the payload builder both submit paths // share. See client/src/hooks/useVideoGenForm.js. const { @@ -238,6 +245,7 @@ export default function VideoGen() { } = useVideoGenForm({ models, modelContext, availableLoras, grokEnabled, falEnabled, reactorEnabled, remoteSubmissionFields: remoteTarget.isRemote ? remoteTarget.submissionFields : null, + displaySleepEnabled, }); // Conditioning the selected peer model cannot take. The server refuses a job @@ -298,6 +306,15 @@ export default function VideoGen() { const localResolutionOptions = resolutionOptionsForModel(currentModel); const localResolutionBounds = videoEdgeBoundsForModel(currentModel); + // Can THIS render put the display to sleep at all? The model says whether + // its runtime needs the mitigation (mlx only); the other clauses rule out + // backends the mitigation never applies to. UI-only: buildGeneratePayload() + // decides independently (from currentModel alone) whether to attach the + // choice, since its grok/fal/reactor/remote branches already return first. + const canSleepDisplay = !!currentModel?.sleepsDisplayDuringRender + && !remoteTarget.isRemote && !isGrok && !isFal && !isReactor; + const rendersSleepDisplay = canSleepDisplay && displaySleepEnabled; + // Every gallery-image slot on this page (both frame panels, each multi-keyframe // row, each IC-LoRA reference row) opens the SAME GalleryImagePicker modal the // Image Gen i2i form uses — a searchable thumbnail grid over the whole gallery. @@ -554,6 +571,9 @@ export default function VideoGen() { // attachJobEvents runs. if (runTokenRef.current > 0 || eventSourceRef.current) return; applyResumedParams(job.params || {}); + // The form hook doesn't own this page-level toggle — restore it directly + // so a reload mid-render shows the choice that render is actually keeping. + if (job.params?.displaySleep !== undefined) setDisplaySleepOverride(!!job.params.displaySleep); setGenerating(true); setPhase(job.status === 'queued' ? 'queued' : null); // The worker's own start time, so a reload keeps a truthful elapsed clock @@ -726,14 +746,6 @@ export default function VideoGen() { const progressPct = progress?.progress != null ? Math.round(progress.progress * 100) : null; - // Will this render put the display to sleep? Both halves are server-owned so - // the warning can't drift from the behaviour: the model says whether its - // runtime needs the mitigation, and /status says whether this install will - // actually apply it (macOS, and the user hasn't opted out). - const rendersSleepDisplay = !!status?.displaySleepOnRender - && !!currentModel?.sleepsDisplayDuringRender - && !remoteTarget.isRemote && !isGrok && !isFal && !isReactor; - // Run a single payload through the SSE pipeline. Returns a promise that // resolves when the job completes (or rejects on error / cancel). The // separate queue-submit path below deliberately does not attach SSE: the @@ -1612,6 +1624,30 @@ export default function VideoGen() { )} + {/* Visible per-render control rather than a settings-only default (off by + default — see ImageGenTab's videoGenDisplaySleep) — a GPU-watchdog + crash on this model is rare enough that most renders shouldn't pay for + the mitigation, but the option needs to be one click away right here. */} + {canSleepDisplay && ( + + )} + {/* Said BEFORE the button is pressed, not after the screen is already dark. A user who first learns about the sleep by watching their display go black reads it as a crash and wakes it — which puts @@ -1621,9 +1657,9 @@ export default function VideoGen() {

- This model renders with your display asleep. The screen will go dark shortly - after you start — that is expected, and waking it can crash the render. Disable it - under Settings → Media Generation if you would rather keep the screen on. + This render will put your display to sleep. The screen will go dark shortly + after you start — that is expected, and waking it can crash the render. Uncheck + the option above if you would rather keep the screen on.

)} diff --git a/server/lib/validation.js b/server/lib/validation.js index 4e6d6d8c7e..4715e5dd00 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -1636,8 +1636,11 @@ export const renderDefaultsSettingsSchema = z.object( export const videoGenSettingsSchema = z.object({ mode: videoModePinSchema, defaultModelId: z.preprocess(emptyToNull, z.string().trim().max(64).nullable().optional()), - // Default-on macOS GPU-watchdog mitigation for sustained MLX video renders. - // Set false for a headless display workflow that manages display power itself. + // Opt-in macOS GPU-watchdog mitigation for local MLX video renders (mlx + // #3267) — OFF by default, since a render is a short, attended action and + // sleeping the screen unasked reads as a crash. Set true to have PortOS + // sleep the display for the duration of a render; also settable per-render + // (see the `displaySleep` field on POST /api/video-gen). displaySleep: z.boolean().optional(), // Install-wide acknowledgement of restricted-model license gates, stored as // the exact reviewed-license ids (`termsGate.id`). Written through diff --git a/server/routes/videoGen.js b/server/routes/videoGen.js index 4b2443fbd5..6514216154 100644 --- a/server/routes/videoGen.js +++ b/server/routes/videoGen.js @@ -73,7 +73,7 @@ import { streamVideoRuntimeInstall, } from '../services/videoGen/runtimeInstaller.js'; import { detectSystemCapabilities, withHardwareCompatibility } from '../lib/systemCapabilities.js'; -import { isDisplaySleepEnabled } from '../services/displayPower.js'; +import { isDisplaySleepEnabled } from '../services/videoGen/displayPower.js'; const router = Router(); @@ -286,6 +286,12 @@ const generateBodySchema = z.object({ ...LOCAL_ONLY_VIDEO_PARAMS, audioStartSec: optionalNum(0, 36000, 'audioStartSec'), disableAudio: z.union([z.boolean(), z.literal('true'), z.literal('false')]).optional(), + // Per-render override of the install-wide display-sleep default + // (settings.videoGen.displaySleep, opt-in). Absent means "use the install + // default" — the local-only branch of submitVideoGenJob only forwards this + // when the client actually sent a choice, so an omitted field can never + // clobber the settings-level default with a stale false. + displaySleep: z.union([z.boolean(), z.literal('true'), z.literal('false')]).optional(), sourceImageFile: z.string().max(512).optional(), // Gallery-pick filename for the FFLF end-frame. The end-frame can also // arrive as a multipart `lastImage` upload (handled below) — when both @@ -477,7 +483,7 @@ router.get('/status', asyncHandler(async (_req, res) => { // reject the whole /status response. runtime: await resolveRuntimeFingerprint().catch(() => null), // Will a render on this install actually sleep the display? macOS-only, and - // the user can opt out (settings.videoGen.displaySleep). Paired with each + // OFF unless the user opted in (settings.videoGen.displaySleep). Paired with each // model's `sleepsDisplayDuringRender`, this is what lets the UI warn BEFORE // the screen goes dark — a user who is not warned reads it as a crash and // wakes the display, re-introducing the exact GPU-watchdog contention the @@ -881,7 +887,7 @@ const ACTIVE_JOB_PARAM_FIELDS = [ 'prompt', 'negativePrompt', 'modelId', 'width', 'height', 'numFrames', 'fps', 'steps', 'guidanceScale', 'seed', - 'tiling', 'disableAudio', 'mode', 'chunks', 'chunkPrompts', 'contextFrames', 'imageStrength', + 'tiling', 'disableAudio', 'displaySleep', 'mode', 'chunks', 'chunkPrompts', 'contextFrames', 'imageStrength', // Plain enum, no path — safe to echo so a reloading page restores the promise the // in-flight render is actually keeping. 'i2vReferenceMode', diff --git a/server/routes/videoGen.test.js b/server/routes/videoGen.test.js index aa602080fd..ed0a84d2c2 100644 --- a/server/routes/videoGen.test.js +++ b/server/routes/videoGen.test.js @@ -76,7 +76,7 @@ vi.mock('../services/videoGen/runtimes.js', async (importOriginal) => ({ resolveByovRuntimeLoraCapable: vi.fn(async (runtime) => runtime === 'minimax_h3' && loraCapability.capable), })); -vi.mock('../services/displayPower.js', () => ({ +vi.mock('../services/videoGen/displayPower.js', () => ({ isDisplaySleepEnabled: vi.fn(() => false), })); @@ -426,7 +426,7 @@ describe('videoGen routes', () => { // macOS-only, so a live call returns false on every other runner and the // assertions would pass against a hardcoded false — pinning nothing. it('reports whether a render will sleep the display, and passes the videoGen slice', async () => { - const { isDisplaySleepEnabled } = await import('../services/displayPower.js'); + const { isDisplaySleepEnabled } = await import('../services/videoGen/displayPower.js'); const { getSettings } = await import('../services/settings.js'); isDisplaySleepEnabled.mockReturnValueOnce(true); diff --git a/server/services/displayPower.js b/server/services/displayPower.js index 0d3afe0dd7..2cb6619c28 100644 --- a/server/services/displayPower.js +++ b/server/services/displayPower.js @@ -8,8 +8,13 @@ import { spawn } from '../lib/childProcess.js'; import { platform } from 'os'; -export const isDisplaySleepEnabled = (settings) => ( - platform() === 'darwin' && settings?.displaySleep !== false +// `defaultEnabled` is the one thing that differs between callers (LoRA +// training defaults ON — an unattended multi-hour run — while video gen +// defaults OFF — a short, attended action, see services/videoGen/displayPower.js). +// Both share the same darwin check and the same explicit-flag semantics, so +// that's the only axis a caller with a different default needs to pass. +export const isDisplaySleepEnabled = (settings, { defaultEnabled = true } = {}) => ( + platform() === 'darwin' && (defaultEnabled ? settings?.displaySleep !== false : settings?.displaySleep === true) ); function runPowerCmd(cmd, args) { @@ -19,16 +24,27 @@ function runPowerCmd(cmd, args) { return proc; } -export function sleepDisplay(settings, workload) { - if (!isDisplaySleepEnabled(settings)) return false; +// Unconditional actions — callers with their own enablement gate (e.g. video +// gen's opt-in default, see services/videoGen/displayPower.js) call these +// directly rather than going through the opt-out gate below. +export function sleepDisplayNow(workload) { runPowerCmd('pmset', ['displaysleepnow']); console.log(`🌙 ${workload}: slept the display to avoid the GPU-watchdog panic (mlx #3267)`); return true; } -export function wakeDisplay(settings, workload) { - if (!isDisplaySleepEnabled(settings)) return false; +export function wakeDisplayNow(workload) { runPowerCmd('caffeinate', ['-u', '-t', '5']); console.log(`☀️ ${workload} finished: woke the display`); return true; } + +export function sleepDisplay(settings, workload) { + if (!isDisplaySleepEnabled(settings)) return false; + return sleepDisplayNow(workload); +} + +export function wakeDisplay(settings, workload) { + if (!isDisplaySleepEnabled(settings)) return false; + return wakeDisplayNow(workload); +} diff --git a/server/services/videoGen/displayPower.js b/server/services/videoGen/displayPower.js new file mode 100644 index 0000000000..7bd9a1054a --- /dev/null +++ b/server/services/videoGen/displayPower.js @@ -0,0 +1,31 @@ +/** + * macOS display power control for local MLX video renders (GPU-watchdog + * mitigation, mlx #3267 — same underlying mechanism documented in + * ../loraTraining/displayPower.js). + * + * Unlike LoRA training (a multi-hour unattended run, default ON), a video + * render is a short, user-attended action — sleeping the screen without + * being asked reads as a crash. So this gate is OPT-IN: only + * settings.videoGen.displaySleep === true enables it, and it can also be + * turned on/off per render (see the `displaySleep` request field on + * POST /api/video-gen). + */ +import { + isDisplaySleepEnabled as sharedDisplaySleepEnabled, + sleepDisplayNow, + wakeDisplayNow, +} from '../displayPower.js'; + +export function isDisplaySleepEnabled(settings) { + return sharedDisplaySleepEnabled(settings, { defaultEnabled: false }); +} + +export function sleepDisplayForVideo(settings, workload = 'Video generation') { + if (!isDisplaySleepEnabled(settings)) return false; + return sleepDisplayNow(workload); +} + +export function wakeDisplayForVideo(settings, workload = 'Video generation') { + if (!isDisplaySleepEnabled(settings)) return false; + return wakeDisplayNow(workload); +} diff --git a/server/services/videoGen/displayPower.test.js b/server/services/videoGen/displayPower.test.js new file mode 100644 index 0000000000..d3fd750fdc --- /dev/null +++ b/server/services/videoGen/displayPower.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Hoisted mock state so the os/child_process factories can reach it. +const h = vi.hoisted(() => ({ + platform: 'darwin', + spawned: [], // [{ cmd, args }] +})); + +vi.mock('os', () => ({ platform: () => h.platform })); +vi.mock('../../lib/childProcess.js', () => ({ + spawn: (cmd, args) => { + h.spawned.push({ cmd, args }); + return { on: () => {}, unref: () => {} }; + }, +})); + +const { isDisplaySleepEnabled, sleepDisplayForVideo, wakeDisplayForVideo } = await import('./displayPower.js'); + +beforeEach(() => { + h.platform = 'darwin'; + h.spawned = []; +}); + +describe('videoGen displayPower', () => { + describe('isDisplaySleepEnabled', () => { + it('is off by default on darwin (absent slice / absent flag) — opt-in, unlike LoRA training', () => { + expect(isDisplaySleepEnabled(undefined)).toBe(false); + expect(isDisplaySleepEnabled({})).toBe(false); + expect(isDisplaySleepEnabled({ displaySleep: false })).toBe(false); + }); + + it('honors an explicit true', () => { + expect(isDisplaySleepEnabled({ displaySleep: true })).toBe(true); + }); + + it('is off on non-darwin even when opted in', () => { + h.platform = 'linux'; + expect(isDisplaySleepEnabled({ displaySleep: true })).toBe(false); + }); + }); + + describe('sleepDisplayForVideo', () => { + it('runs `pmset displaysleepnow` only when explicitly opted in', () => { + expect(sleepDisplayForVideo({ displaySleep: true })).toBe(true); + expect(h.spawned).toEqual([{ cmd: 'pmset', args: ['displaysleepnow'] }]); + }); + + it('is a no-op (no spawn) by default', () => { + expect(sleepDisplayForVideo({})).toBe(false); + expect(h.spawned).toEqual([]); + }); + + it('is a no-op off darwin even when opted in', () => { + h.platform = 'win32'; + expect(sleepDisplayForVideo({ displaySleep: true })).toBe(false); + expect(h.spawned).toEqual([]); + }); + }); + + describe('wakeDisplayForVideo', () => { + it('runs `caffeinate -u -t 5` when opted in', () => { + expect(wakeDisplayForVideo({ displaySleep: true })).toBe(true); + expect(h.spawned).toEqual([{ cmd: 'caffeinate', args: ['-u', '-t', '5'] }]); + }); + + it('is a no-op by default (so we never wake a display we did not sleep)', () => { + expect(wakeDisplayForVideo({})).toBe(false); + expect(h.spawned).toEqual([]); + }); + }); +}); diff --git a/server/services/videoGen/generateVideo.js b/server/services/videoGen/generateVideo.js index 77015a5502..43d8d1ff29 100644 --- a/server/services/videoGen/generateVideo.js +++ b/server/services/videoGen/generateVideo.js @@ -153,7 +153,7 @@ export const listVideoModels = () => getVideoModels().map(decorateVideoModel); export const defaultVideoModelId = (capabilities) => getDefaultVideoModelId(capabilities); -export async function generateVideo({ pythonPath, prompt, negativePrompt = '', modelId, width = null, height = null, numFrames = null, fps = 24, steps, guidanceScale, seed, tiling = 'auto', disableAudio = false, sourceImagePath = null, uploadedTempPath = null, uploadedTempPaths = [], lastImagePath = null, keyframes = null, extendFromVideoPath = null, audioFilePath = null, audioStartSec = null, mode = null, imageStrength = null, i2vReferenceMode = null, loras = null, icReferencePaths = null, icStrength = null, icAttentionStrength = null, icSkipStage2 = false, textEncoderId = null, speedProfileId = null, draftDecode = null, visualConditioning = null, hidden = false, jobId: providedJobId = null }) { +export async function generateVideo({ pythonPath, prompt, negativePrompt = '', modelId, width = null, height = null, numFrames = null, fps = 24, steps, guidanceScale, seed, tiling = 'auto', disableAudio = false, sourceImagePath = null, uploadedTempPath = null, uploadedTempPaths = [], lastImagePath = null, keyframes = null, extendFromVideoPath = null, audioFilePath = null, audioStartSec = null, mode = null, imageStrength = null, i2vReferenceMode = null, loras = null, icReferencePaths = null, icStrength = null, icAttentionStrength = null, icSkipStage2 = false, textEncoderId = null, speedProfileId = null, draftDecode = null, visualConditioning = null, hidden = false, displaySleep = null, jobId: providedJobId = null }) { uploadedTempPaths = Array.isArray(uploadedTempPaths) ? uploadedTempPaths : []; if (!prompt?.trim()) throw new ServerError('Prompt is required', { status: 400, code: 'VALIDATION_ERROR' }); // Single-flight is now enforced by the mediaJobQueue worker upstream — only @@ -924,7 +924,13 @@ export async function generateVideo({ pythonPath, prompt, negativePrompt = '', m height: h, numFrames: parsedNumFrames, steps: actualSteps, - videoGenSettings: (await getSettings())?.videoGen, + // This render's own choice (the `displaySleep` request field) wins over + // the install-wide settings default so a page reload/resume replays the + // choice the user actually made, not whatever Settings holds now. + videoGenSettings: { + ...(await getSettings())?.videoGen, + ...(displaySleep != null ? { displaySleep } : {}), + }, }); return { jobId, generationId: jobId, filename, mode: 'local', model: modelId }; diff --git a/server/services/videoGen/spawnWatch.js b/server/services/videoGen/spawnWatch.js index 9c524952fb..74881f307d 100644 --- a/server/services/videoGen/spawnWatch.js +++ b/server/services/videoGen/spawnWatch.js @@ -32,7 +32,7 @@ import { invalidateByovReadyCache, pickDeathFingerprint, } from './runtimes.js'; -import { sleepDisplay, wakeDisplay } from '../displayPower.js'; +import { isDisplaySleepEnabled, sleepDisplayForVideo, wakeDisplayForVideo } from './displayPower.js'; import { loadHistory, mutateVideoHistory } from './history.js'; import { estimateRenderMs } from './eta.js'; import { videoJobState } from './jobState.js'; @@ -195,7 +195,7 @@ export async function spawnAndWatchVideo({ // from the Apple menu. `-w` makes caffeinate self-exit when our pid does, so // no manual cleanup is needed and a server crash mid-render still releases // the assertion. macOS-only — `caffeinate` is a darwin binary. - const sleepDisplayForRender = runtimeUsesMlx(model.runtime) && videoGenSettings?.displaySleep !== false; + const sleepDisplayForRender = runtimeUsesMlx(model.runtime) && isDisplaySleepEnabled(videoGenSettings); let displaySlept = false; if (process.platform === 'darwin' && proc.pid) { // MLX renders must keep the system awake but let the display sleep: `-d` @@ -203,7 +203,7 @@ export async function spawnAndWatchVideo({ // watchdog. Other runtimes keep their existing display-awake behavior. const caffeineArgs = sleepDisplayForRender ? ['-is', '-w', String(proc.pid)] : ['-dis', '-w', String(proc.pid)]; spawn('caffeinate', caffeineArgs, { stdio: 'ignore', detached: false }).on('error', () => {}); - displaySlept = sleepDisplayForRender && sleepDisplay(videoGenSettings, 'Video generation'); + displaySlept = sleepDisplayForRender && sleepDisplayForVideo(videoGenSettings, 'Video generation'); } // Guards the ONE terminal run of this child's teardown, across BOTH terminal // paths ('error' and 'close'). The caller may have to replay a terminal @@ -225,7 +225,7 @@ export async function spawnAndWatchVideo({ broadcastSse(job, { type: 'error', error: reason }); videoGenEvents.emit('failed', { generationId: jobId, error: reason }); videoJobState.activeProcess = null; - if (displaySlept) wakeDisplay(videoGenSettings, 'Video generation'); + if (displaySlept) wakeDisplayForVideo(videoGenSettings, 'Video generation'); void releaseHeavyClaim(); // Spawn failed, so proc.on('close') will never fire — clean up every // temp file we own here, including the multipart upload, otherwise @@ -391,7 +391,7 @@ export async function spawnAndWatchVideo({ } finally { // A prompt-encode relaunch returns before this finalizer, deliberately // leaving the display asleep while its replacement owns the GPU. - if (displaySlept) wakeDisplay(videoGenSettings, 'Video generation'); + if (displaySlept) wakeDisplayForVideo(videoGenSettings, 'Video generation'); closeJobAfterDelay(videoJobState.jobs, jobId); } }; diff --git a/server/services/videoGen/submitJob.js b/server/services/videoGen/submitJob.js index f7cfd7397d..aef4d58d39 100644 --- a/server/services/videoGen/submitJob.js +++ b/server/services/videoGen/submitJob.js @@ -279,6 +279,11 @@ const submitValidatedVideoGenJob = async (body, uploads) => { ...(isDefaultSpeedProfile(body.speedProfileId) ? {} : { speedProfileId: body.speedProfileId }), ...(isFullDecode(body.draftDecode) ? {} : { draftDecode: body.draftDecode }), disableAudio: body.disableAudio === true || body.disableAudio === 'true', + // Absent means "use the settings.videoGen.displaySleep default" — only + // forward it when the form actually sent an explicit choice. + ...(body.displaySleep !== undefined + ? { displaySleep: body.displaySleep === true || body.displaySleep === 'true' } + : {}), sourceImagePath, audioFilePath, audioStartSec: body.audioStartSec,