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
26 changes: 18 additions & 8 deletions client/src/components/ModelSelect.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
// it for "optional model / fall back to the server default" pickers (mirrors
// ProviderModelSelector's `emptyModelOption`). `ariaLabel` / `title` let an
// inline picker with no visible <label> stay accessible.
//
// `loading` swaps the options for a disabled "Loading models…" placeholder, so
// a caller whose list arrives from a slow probe can keep the field (and its
// label) in the form instead of letting it pop in late.
const defaultGetLabel = (m) => m.name;
export default function ModelSelect({
models,
Expand All @@ -21,25 +25,31 @@ export default function ModelSelect({
emptyOption,
ariaLabel,
title,
loading = false,
}) {
const active = models.filter((m) => !m.deprecated);
const legacy = models.filter((m) => m.deprecated);
return (
<select
id={id}
value={value}
value={loading ? '' : value}
onChange={onChange}
disabled={disabled}
disabled={disabled || loading}
aria-busy={loading || undefined}
aria-label={ariaLabel}
title={title}
className={className}
>
{emptyOption != null && <option value="">{emptyOption}</option>}
{active.map((m) => <option key={m.id} value={m.id}>{getLabel(m)}</option>)}
{legacy.length > 0 && (
<optgroup label="Legacy">
{legacy.map((m) => <option key={m.id} value={m.id}>{getLabel(m)}</option>)}
</optgroup>
{loading ? <option value="">Loading models…</option> : (
<>
{emptyOption != null && <option value="">{emptyOption}</option>}
{active.map((m) => <option key={m.id} value={m.id}>{getLabel(m)}</option>)}
{legacy.length > 0 && (
<optgroup label="Legacy">
{legacy.map((m) => <option key={m.id} value={m.id}>{getLabel(m)}</option>)}
</optgroup>
)}
</>
)}
</select>
);
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ grep -i "what you want to do" client/src/lib/README.md
| `videoFinish.js` | Finish-a-draft gate (#3696). `isReproducibleTextToVideo(record)` is true only for a single text-to-video render whose history record already carries everything a re-render needs — `renderInputsVersion` (the positive marker that its conditioning inventory is trustworthy, so legacy records degrade to not-finishable rather than reading as unconditioned), an empty `conditioning` array, a resolved seed, a real prompt, and no stitched/chained/upscaled provenance. `finishTargetForRecord(record, models)` additionally resolves the draft model's server-declared `finishModelId` against the models this install can actually run, returning the delivery model entry or `null`. `isDeliveryVideoModel(model, models)` is the other end of the same graph — true when some entry names `model` as its `finishModelId`, which is what makes a delivery render always decode on the full decoder (#5423). Mirrors `finishTargetForModel` / `isDeliveryVideoModel` in `server/lib/videoFinishProfiles.js`; the pair table itself stays server-side. |
| `videoGenParams.js` | Pure VideoGen param helpers: `FRAME_OPTIONS`/`FPS_OPTIONS`/`VIDEO_EDGE_BOUNDS`/`MAX_CHUNKS`/`CHUNK_OPTIONS`/`DEFAULT_CONTEXT_FRAMES`/`CONTEXT_FRAME_OPTIONS` constants, model-aware frame/fps/resolution-grid normalization, separate mute vs prompt-audio capability checks, `supportsContextWindow(model)` (does this runtime have an extend pipeline to feed a continuation window to — display mirror of `server/lib/videoContinuity.js`, pinned by `server/lib/videoContinuity.parity.test.js`), `videoModelMemoryGb()` (model memory footprint), `selectVideoMemoryProfile(model, systemMemoryGb)` + `VIDEO_MEMORY_RESERVE_GB` (#5420 — which declared weight-placement profile this machine can actually hold, out of the `memoryProfiles` the server decorates onto the entry; the floors ride on the model so only the reserve is mirrored, and an unmeasured `systemMemoryGb` returns a `null` usable figure rather than reading as a box that is too small), `computeFflfSafeFrames()` (FFLF/ltx2 pixel-budget back-solve, mirrors `server/services/videoGen/local.js`), and `isModelAllowedForMode()` (a2v uses the shared audio-runtime capability; IC remix remains LTX-only). Speed profiles (#4875) mirror the conditioner shape: `DEFAULT_SPEED_PROFILE_ID` (must equal `SPEED_PROFILE_DEFAULT_ID` in `server/lib/videoSpeedProfiles.js` — absence and `'quality'` are the same request), `isDefaultSpeedProfileId()` mirrors the server's absence-is-the-default rule, `speedProfilesForModel()` reads the server-decorated `speedProfiles` off the entry, `speedProfilesForMode(model, mode)` applies the SAME mode gate the server's `speedProfileDeclineReason` does (so a profile the server would decline is never offered, nor allowed to lock the dials), `normalizeSpeedProfileForModel()` snaps a selection onto what a just-switched model declares (mode-independent, so switching to fflf hides the picker without rewriting the choice), `speedProfileIdFromRecord()` reads one back out of a history entry / resumed job, `selectedSpeedProfile(id, model, mode)` resolves the profile actually driving the render — what the picker shows and what disables Steps+CFG — and `videoChainChunkModes({ model, mode, chaining, contextFrames, hasSourceImage })` derives the modes a CHAINED request’s chunks will run in (chunk 0 the request’s, chunks 1+ `extend` on a window-continuity chain or `image` on a frame hop), mirroring `generateChainedVideo`’s dispatch and `resolveContinuityStrategy`; `resolveContextFramesForDisplay()` is the absent/invalid→`DEFAULT_CONTEXT_FRAMES` half of `resolveContextFrames` that gate depends on. All pinned by `server/lib/videoSpeedProfiles.parity.test.js`. Also mirrors the IC-LoRA remix registry (`IC_LORA_MODES`/`IC_LORA_MODE_VALUES`/`isIcLoraMode()`/`icLoraSpecForMode()`/`icResolutionIssue()`) from `server/lib/icLoraWeights.js` so the form validates a reference render pre-submit — pinned by `server/lib/icLoraWeights.parity.test.js`. |
| `videoGenResolutions.js` | Shared resolution presets/default for video generation, model-specific preset/default resolvers (for native canvases such as MiniMax H3), and `snapAspectToImage()` to pick the closest-aspect preset for an I2V source. |
| `videoGenStatusCache.js` | Session-scoped cache of the model-shaping half of `GET /api/video-gen/status` (`readCachedVideoGenStatus` / `writeCachedVideoGenStatus` / `VIDEO_GEN_STATUS_CACHE_KEY`), so the Video Gen Model picker paints from the previous answer instead of waiting on the python probe behind that route. Stores only the model list plus `defaultModel` / `systemMemoryGb` and hands them back marked `stale: true`; every python-health field is dropped rather than guarded, so a stored answer can never report connectivity. |
| `videoGenSubmission.js` | Builds the local, Grok, and federated video-generation request bodies from validated form state, including prompt envelopes and empty-value wire sentinels. |
| `videoReferenceModes.js` | Mirror of `server/lib/videoReferenceModes.js` (parity enforced by `server/lib/videoReferenceModes.mirror.test.js`) — the i2v reference-mode contract: `I2V_REFERENCE_MODES`, `I2V_REFERENCE_MODE_OPTIONS` (label + the promise sentence `AdvancedParamsPanel` and the source-frame note print), `runtimeSupportsI2vReferenceMode` (gates which options the picker offers), `resolveI2vReferenceStrength` (the effective strength the panel displays), and `i2vReferenceModeViolation` for pre-submit feedback. |
| `videoStagePreview.js` | VideoGen main-stage preview resolver (#4588). `resolveVideoStagePreview({ generating, width, height, result, extendSource, sourceImageFile/Url, lastImageFile/Url, keyframes })` → `{ kind: 'loop'|'still'|'result'|'empty', src, poster, label, aspectRatio }` — conditioning media while a render runs and the finished clip once it lands; transient runner frames are intentionally excluded. Plus `videoStageAspectRatio()` (a NUMBER for an inline `aspect-ratio` style — a computed Tailwind `aspect-[w/h]` class never reaches the JIT build; `null` means unknown geometry, not 16:9) and `videoStageSignature()` for the hold/return guard in `components/videoGen/LiveVideoStage.jsx`. |
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export * from './slashdoCatalog.js';
export * from './videoFinish.js';
export * from './videoGenParams.js';
export * from './videoGenResolutions.js';
export * from './videoGenStatusCache.js';
export * from './videoGenSubmission.js';
export * from './videoReferenceModes.js';
export * from './videoStagePreview.js';
Expand Down
43 changes: 43 additions & 0 deletions client/src/lib/videoGenStatusCache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Session-scoped cache of the model-shaping half of `GET /api/video-gen/status`.
*
* That probe shells out to python and rebuilds the hardware-aware model list on
* every call, so a cold Video Gen page load leaves the Model picker with nothing
* to render for a second or two. Caching lets the picker paint from the previous
* answer while the live probe revalidates behind it.
*
* Only `CACHED_FIELDS` is stored, and the read hands it back marked
* `stale: true`. Everything the payload says about python health — `connected`,
* `reason`, `missingPackages`, `pythonPath`, `byovRuntimes`, `runtime` — is
* deliberately dropped rather than guarded, because an interpreter the user just
* fixed (or just broke) must never be reported from a stored answer, and a field
* that isn't there can't be read by mistake.
*
* Session, not local: the model registry and the python environment both move
* with an upgrade or an install, and a payload kept for weeks would outlive
* both.
*/
import { safeReadJsonSession, safeWriteJsonSession } from './safeStorage.js';

// Bump the suffix when `CACHED_FIELDS` changes, so an older tab's entry is
// ignored rather than half-read.
export const VIDEO_GEN_STATUS_CACHE_KEY = 'portos.videoGenStatus.v1';

// The model list plus the numbers that decide which model is selected for it.
const CACHED_FIELDS = ['models', 'defaultModel', 'systemMemoryGb'];

// Returns the cached fields with `stale: true`, or null when nothing usable is
// stored. An entry with no `models` array is worthless here — painting the
// picker is the whole point — so it reads as absent.
export const readCachedVideoGenStatus = () => {
const cached = safeReadJsonSession(VIDEO_GEN_STATUS_CACHE_KEY);
if (!cached || typeof cached !== 'object' || !Array.isArray(cached.models)) return null;
return { ...cached, stale: true };
};

// Store the cacheable slice of a freshly fetched payload.
export const writeCachedVideoGenStatus = (status) => {
if (!status || typeof status !== 'object' || !Array.isArray(status.models)) return;
const slice = Object.fromEntries(CACHED_FIELDS.map((field) => [field, status[field]]));
safeWriteJsonSession(VIDEO_GEN_STATUS_CACHE_KEY, slice);
};
31 changes: 24 additions & 7 deletions client/src/pages/VideoGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import ResolutionField from '../components/media/ResolutionField';
import { VIDEO_EDGE_BOUNDS, videoEdgeBoundsForModel, IC_LORA_MODES } from '../lib/videoGenParams.js';
import { finishTargetForRecord, isDeliveryVideoModel } from '../lib/videoFinish.js';
import { peerModelRequiresInput } from '../lib/federatedMediaReadiness.js';
import { readCachedVideoGenStatus, writeCachedVideoGenStatus } from '../lib/videoGenStatusCache.js';
const MODES = [
{ id: 'text', label: 'Text', icon: Type, desc: 'Text-to-video' },
{ id: 'image', label: 'Image', icon: ImageIcon, desc: 'Image-to-video (start frame)' },
Expand All @@ -121,7 +122,11 @@ export default function VideoGen() {
refreshGrokEnabled();
};

const [status, setStatus] = useState(null);
// Paint the model picker from the previous /status answer while the live
// probe runs. The cached entry carries `stale: true` and holds nothing but
// the model-shaping fields (see lib/videoGenStatusCache.js); connectivity UI
// below gates on `statusFresh`.
const [status, setStatus] = useState(readCachedVideoGenStatus);
const [statusLoading, setStatusLoading] = useState(true);
// Grok Build CLI video backend (#2859 phase 2) — surfaced only when the
// user enabled Grok in Settings → Image Gen (one toggle covers image +
Expand All @@ -130,7 +135,7 @@ export default function VideoGen() {
// The jobId of the render this tab's Generate button currently owns —
// threaded into cancelVideoGen so cancellation is job-scoped.
const activeJobIdRef = useRef(null);
const [models, setModels] = useState([]);
const [models, setModels] = useState(() => status?.models || []);
const refreshGrokEnabled = useCallback(() => {
getSettings({ silent: true })
.then((sv) => setGrokEnabled(sv?.imageGen?.grok?.enabled === true))
Expand Down Expand Up @@ -401,6 +406,7 @@ export default function VideoGen() {
.then((s) => {
setStatus(s);
setModels(s.models || []);
writeCachedVideoGenStatus(s);
})
.catch(() => setStatus({ connected: false, reason: 'Status check failed' }))
.finally(() => setStatusLoading(false));
Expand Down Expand Up @@ -800,7 +806,16 @@ export default function VideoGen() {
// `byovRuntimeMissing` for those models. Without this, a user who installed
// ONLY a BYOV runtime via the modal would stay stuck behind a "not
// configured" error from the unrelated legacy probe.
const notConnected = !!status && status.connected === false && !needsByovProbe;
// A cached entry says nothing about python health, so the connectivity UI
// waits for the live probe rather than reporting the interpreter state of
// whenever the last visit happened.
const statusFresh = !!status && !status.stale;
// The Model field renders as soon as there is anything to say — the list, or
// the fact that it is still being probed. Only a finished probe that named no
// model at all takes the field away.
const modelsLoading = models.length === 0;
const modelFieldVisible = !modelsLoading || statusLoading;
const notConnected = statusFresh && status.connected === false && !needsByovProbe;

// A federated render answers to the PEER’s readiness, not to this machine’s
// runtime gates — none of the local probes below describe the hardware it
Expand All @@ -814,7 +829,7 @@ export default function VideoGen() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 text-xs">
{status ? (
{statusFresh ? (
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full border ${
status.connected
Expand Down Expand Up @@ -870,7 +885,7 @@ export default function VideoGen() {

<RuntimeFingerprint runtime={status?.runtime} />

{status && status.connected === false && (() => {
{statusFresh && status.connected === false && (() => {
const missingCount = status.missingPackages?.length || 0;
const hasPath = !!status.pythonPath;
return (
Expand Down Expand Up @@ -1239,13 +1254,15 @@ export default function VideoGen() {
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{/* The peer advertises its own models; the local list would name
none of them, and a stale selection here must not read as the
model that rendered the clip. */}
{models.length > 0 && !remoteTarget.isRemote && (
model that rendered the clip. Locally the field holds its place
through the probe rather than popping into the form late. */}
{modelFieldVisible && !remoteTarget.isRemote && (
<FormField className="col-span-2 sm:col-span-3" label="Model" labelClassName="block text-xs font-medium text-gray-400 mb-1">
<ModelSelect
models={visibleModels}
value={modelId}
onChange={(e) => handleModelChange(e.target.value)}
loading={modelsLoading}
/>
{remixModelFallback && (
<p className="mt-1 text-[11px] text-port-accent leading-snug" role="status">
Expand Down
Loading