Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions client/src/components/settings/ImageGenTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: '',
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -791,7 +791,7 @@ export function ImageGenTab() {
/>
<span>
<span className="block font-medium text-white">Sleep display during local MLX video renders</span>
<span className="block text-xs text-gray-500 mt-0.5">Keeps the system awake while reducing WindowServer GPU contention on affected Apple silicon. Turn off only when another headless workflow manages display power.</span>
<span className="block text-xs text-gray-500 mt-0.5">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.</span>
</span>
</label>
<FormField
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/settings/ImageGenTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ describe('ImageGenTab grouped tabs', () => {
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: {},
});
});
});
Expand Down
9 changes: 8 additions & 1 deletion client/src/hooks/useVideoGenForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions client/src/lib/videoGenSubmission.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions client/src/pages/VideoGen.displaySleep.test.jsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
58 changes: 47 additions & 11 deletions client/src/pages/VideoGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1612,6 +1624,30 @@ export default function VideoGen() {
)}
</div>

{/* 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 && (
<label htmlFor="video-gen-display-sleep" className="flex items-start gap-2 text-xs text-gray-400 cursor-pointer">
<input
id="video-gen-display-sleep"
type="checkbox"
checked={displaySleepEnabled}
onChange={(e) => setDisplaySleepOverride(e.target.checked)}
disabled={generating}
className="mt-0.5 accent-port-accent"
/>
<span>
Sleep display during this render
<span className="block text-[11px] text-gray-500">
Reduces WindowServer GPU contention on affected Apple silicon. Change the install-wide
default under Settings &rarr; Media Generation.
</span>
</span>
</label>
)}

{/* 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
Expand All @@ -1621,9 +1657,9 @@ export default function VideoGen() {
<p className="flex items-start gap-1.5 text-[11px] text-port-warning">
<MonitorOff className="w-3.5 h-3.5 mt-px shrink-0" />
<span>
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 &rarr; 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.
</span>
</p>
)}
Expand Down
7 changes: 5 additions & 2 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions server/routes/videoGen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions server/routes/videoGen.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));

Expand Down Expand Up @@ -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);
Expand Down
Loading