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
7 changes: 7 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,10 @@
- **[issue-3133] Every reviewer chain in PortOS now reads as one per-reviewer table — Provider, Model, Optional, Max Iterations — and you can pick a reviewer's model from all four places that show a reviewer chain, not just the settings panel.** Code Review Defaults used to render reviewers as a chip row with a stack of per-backend model dropdowns floating below it; that model-picking logic lived in the panel, so the Add Task form, a task type's Schedule config, and the Agent Operations run drawer — all of which reuse the same picker — had no way to say which model a reviewer should run. Each reviewer is now a row carrying all four of its controls, so "run codex on this tier, cap ollama at one round, and don't let it block the merge" is one readable line instead of three controls in three places. On a phone the row stacks into labelled fields rather than scrolling sideways.
- A local backend (LM Studio / Ollama) offers the models you actually have installed; Codex and Claude accept a typed id as well as a pick, because an Ollama-backed `claude` or a Bedrock-form model id is whatever your machine provides and can't be enumerated. A pinned model that's since been uninstalled is still shown as pinned rather than rendering blank and reading as unset, and a backend that isn't running says so instead of just looking empty.
- The model pin now belongs to the task, not only to the install: a per-task or per-task-type choice overrides your Code Review Defaults the same way its `~opt` and round-cap siblings already do, and an explicitly empty choice means "each reviewer's own default" rather than silently inheriting. Clearing the field removes the pin instead of storing an empty model id. Your saved Code Review Defaults are read and written unchanged, so nothing needs migrating.

## Video Gen — Ingredients remix

- **[issue-3112] Local LTX-2 video gen can now remix a scene from 2-8 reference stills.** A new **Ingredients** mode joins Control and Colorize: instead of one reference clip it takes a 2-8 row list of gallery images — characters, props, settings — and recomposes them into a new shot from your prompt. The picker is the same multi-row gallery list the keyframe anchors use (add/remove rows, inline previews, a live 2/8 counter), and it won't let you submit an unfilled row or a count the weight can't accept. The existing single-reference clip modes are untouched.
- **The weight is fetched one file at a time, never as a repo snapshot.** Ingredients' first-party weight repo is gated, and the un-gated mirror that carries it is a ~708 GB aggregate of every LTX weight — so pulling it the ordinary way would either 401 or fill your disk to get 1.3 GB. The download helper gained a real single-file mode, and the weight download tries the official repo first (for anyone with an HF token) and falls back to the mirror automatically, pulling exactly the one file either way. No token is required and no extra button to press.
- Because neither of those repos can safely be auto-downloaded mid-render, an Ingredients render on a machine that hasn't fetched the weight fails immediately with "download it first" instead of quietly starting an unbounded pull inside the render. Its cache, repair and integrity checks are likewise scoped to that one file rather than walking the whole mirror.
- Ingredients conditions on stills, but the pipeline's reference channel is a video encoder end to end, so each still is prepared into a tiny clip at the render resolution before conditioning — that plumbing is internal and the throwaway encodes are cleaned up with the render. User LoRAs still stack on top of the weight rather than replacing it, so an Ingredients × Character render composes. The 2-8 count is enforced in the form, at the route and in the render helper, all reading one shared bound.
111 changes: 97 additions & 14 deletions client/src/components/videoGen/IcLoraPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,45 @@
/**
* IC-LoRA remix panel (issue #3100) — dgrauet/ltx2 runtime only. The user
* supplies a reference clip (upload or a prior render) that the fused IC-LoRA
* reads structure/motion out of, then dials how strongly it conditions.
* supplies the reference(s) the fused IC-LoRA conditions on, then dials how
* strongly they apply.
*
* Presentational — the reference upload File, the picked history ids, the
* strength dials, the weight's download status, and the "no compatible model
* installed" condition are all owned by the VideoGen page. `onPickHistory('')`
* clears the history selection.
* Two input surfaces, chosen by the registry's `spec.referenceKind`:
* - `video` (Control, Colorize) — ONE reference clip: a fresh upload or a
* prior render picked by history id, mutually exclusive server-side.
* - `image` (Ingredients, #3112) — a 2-8 ROW gallery list, modeled on
* KeyframePanel. Gallery-only, matching the route (references resolve under
* PATHS.images, so there's no upload shape to reconcile).
*
* Presentational — the reference upload File, the picked history id, the gallery
* reference rows, the strength dials, the weight's download status, and the "no
* compatible model installed" condition are all owned by the VideoGen page.
* `onPickHistory('')` clears the history selection.
*
* The IC-LoRA weight is a separate several-hundred-MB HF pull, so the panel
* hosts its own ModelDownloadBadge: without it a first render silently stalls
* on an un-progressed download inside the Python child.
*/
import { Upload, Film } from 'lucide-react';
import { Upload, Film, X, ListPlus } from 'lucide-react';
import { formatBytes } from '../../utils/formatters';
import { icResolutionIssue } from '../../lib/videoGenParams';
import FilePickerButton from '../ui/FilePickerButton';
import ModelDownloadBadge from '../media/ModelDownloadBadge';
import ImagePreview from './ImagePreview';
import Ltx2RuntimeMissingNotice from './Ltx2RuntimeMissingNotice';

export default function IcLoraPanel({
spec, // { mode, label, desc, referenceDownscaleFactor, min/maxReferences }
referenceFile, // File | null — fresh upload
spec, // { mode, label, desc, referenceDownscaleFactor, min/maxReferences, referenceKind }
referenceFile, // File | null — fresh upload (video-kind only)
referenceVideoId, // string — picked prior render (mutually exclusive with the upload)
inFlightReferenceNames = [], // display-only basenames of a resumed in-flight render
visibleHistory,
// Image-kind (Ingredients) reference rows: gallery basenames, min/max enforced
// by the registry spec. The page owns the array and the three mutators.
referenceImageFiles = [],
visibleGallery = [],
onAddReferenceImage,
onUpdateReferenceImage,
onRemoveReferenceImage,
icStrength,
icSkipStage2,
width,
Expand All @@ -44,12 +59,24 @@ export default function IcLoraPanel({
const resolutionIssue = icResolutionIssue(spec, width, height);
const strengthId = `ic-strength-${spec.mode}`;
const skipId = `ic-skip-stage2-${spec.mode}`;
const imageKind = spec.referenceKind === 'image';
// Row count is a WEIGHT CONTRACT, not a UI preference — read the bounds off the
// spec so a future weight with different limits needs no component change (and
// can't disagree with what the route enforces).
const { minReferences: minRefs, maxReferences: maxRefs } = spec;

return (
<div className="border border-port-border/50 rounded-lg p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-gray-400">{spec.label} reference</span>
{(referenceFile || referenceVideoId) && (
<span className="text-[11px] font-medium text-gray-400">
{spec.label} reference{imageKind ? 's' : ''}
{imageKind && (
<span className="block text-[10px] text-gray-500 font-normal">
Pick {minRefs}–{maxRefs} gallery stills to recompose from
</span>
)}
</span>
{!imageKind && (referenceFile || referenceVideoId) && (
<button
type="button"
onClick={() => { onClearFile(); onPickHistory(''); }}
Expand All @@ -60,7 +87,54 @@ export default function IcLoraPanel({
)}
</div>

{referenceFile ? (
{imageKind ? (
<div className="space-y-2">
{referenceImageFiles.map((file, i) => (
<div key={i} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<label htmlFor={`ic-ref-img-${i}`} className="sr-only">{`${spec.label} reference ${i + 1} gallery image`}</label>
<select
id={`ic-ref-img-${i}`}
value={file}
onChange={(e) => onUpdateReferenceImage(i, e.target.value)}
className="w-full bg-port-bg border border-port-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-port-accent"
>
<option value="">Pick from gallery…</option>
{visibleGallery.map((img) => (
<option key={img.filename} value={img.filename}>{img.filename}</option>
))}
</select>
{file && (
<ImagePreview src={`/data/images/${file}`} alt={`${spec.label} reference ${i + 1}`} label={file} />
)}
</div>
<button
type="button"
onClick={() => onRemoveReferenceImage(i)}
disabled={referenceImageFiles.length <= minRefs}
aria-label={`Remove reference ${i + 1}`}
className="mt-1 p-1 text-gray-400 hover:text-port-error disabled:opacity-30 disabled:cursor-not-allowed"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onAddReferenceImage}
disabled={referenceImageFiles.length >= maxRefs}
className="flex items-center gap-1.5 text-[11px] text-port-accent hover:text-port-accent/80 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ListPlus className="w-3.5 h-3.5" /> Add reference
</button>
<span className="text-[10px] text-gray-500">{referenceImageFiles.length}/{maxRefs}</span>
</div>
<p className="text-[10px] text-gray-500 leading-snug">
References pull from your gallery only. Each still is prepared at the render resolution before conditioning.
</p>
</div>
) : referenceFile ? (
<div className="flex items-center gap-2 text-[11px] text-gray-300">
<Film className="w-3.5 h-3.5 text-port-accent" />
<span className="truncate" title={referenceFile.name}>{referenceFile.name}</span>
Expand Down Expand Up @@ -92,7 +166,7 @@ export default function IcLoraPanel({
</>
)}

{!referenceFile && !referenceVideoId && inFlightReferenceNames.length > 0 && (
{!imageKind && !referenceFile && !referenceVideoId && inFlightReferenceNames.length > 0 && (
<p className="text-[10px] text-gray-500">
In-flight render is conditioned on {inFlightReferenceNames.join(', ')} — re-pick a reference to run a new one.
</p>
Expand Down Expand Up @@ -127,9 +201,18 @@ export default function IcLoraPanel({
</div>

<p className="text-[10px] text-gray-500 leading-snug">
{spec.description}. The reference is trimmed to fit the frame count.
{spec.description}.
{imageKind ? '' : ' The reference is trimmed to fit the frame count.'}
</p>

{weightStatus?.gated && !weightStatus.cached && (
<p className="text-[10px] text-gray-500 leading-snug">
The official weight repo is gated. Download tries it first (accept its license and add an
HF token in Image Gen settings for that path) and falls back to the un-gated mirror
automatically, so no token is required.
</p>
)}

{resolutionIssue && (
<p className="text-[11px] text-port-warning">
{resolutionIssue} Adjust the resolution first.
Expand Down
81 changes: 81 additions & 0 deletions client/src/components/videoGen/IcLoraPanel.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,84 @@ describe('IcLoraPanel', () => {
expect(screen.getByText(/In-flight render is conditioned on depth\.mp4/i)).toBeTruthy();
});
});

// ── Image-kind (Ingredients) surface (#3112) ───────────────────────────────
// referenceKind: 'image' swaps the single upload/history pair for a 2-8 gallery
// ROW list, modeled on KeyframePanel. Bounds are read off the spec, never
// hardcoded, so they can't disagree with what the route enforces.
describe('IcLoraPanel — image-kind references (#3112)', () => {
const ING = icLoraSpecForMode('ic-ingredients');
const imageProps = {
...baseProps,
spec: ING,
referenceImageFiles: ['owl.png', ''],
visibleGallery: [
{ filename: 'owl.png' }, { filename: 'store.png' }, { filename: 'tote.png' },
],
weightStatus: { id: 'ic-ingredients', repo: 'org/ingredients', cached: true, sizeBytes: 1_308_778_338 },
onAddReferenceImage: vi.fn(),
onUpdateReferenceImage: vi.fn(),
onRemoveReferenceImage: vi.fn(),
};

it('renders one gallery row per reference, not the clip upload/history pair', () => {
render(<IcLoraPanel {...imageProps} />);
expect(screen.getByLabelText(/Ingredients reference 1 gallery image/i)).toBeTruthy();
expect(screen.getByLabelText(/Ingredients reference 2 gallery image/i)).toBeTruthy();
// The video-only inputs must be absent — offering them would let a clip ride
// into an image-kind weight, which the route rejects.
expect(screen.queryByText(/Upload a reference still/i)).toBeNull();
expect(screen.queryByLabelText(/Pick a previous render/i)).toBeNull();
});

it('reports the picked gallery file through onUpdateReferenceImage', () => {
const onUpdateReferenceImage = vi.fn();
render(<IcLoraPanel {...imageProps} onUpdateReferenceImage={onUpdateReferenceImage} />);
fireEvent.change(screen.getByLabelText(/Ingredients reference 2 gallery image/i), {
target: { value: 'store.png' },
});
expect(onUpdateReferenceImage).toHaveBeenCalledWith(1, 'store.png');
});

it('shows the row count against the spec maximum', () => {
render(<IcLoraPanel {...imageProps} />);
expect(screen.getByText('2/8')).toBeTruthy();
});

it('disables Add at the spec maximum', () => {
const eight = Array.from({ length: 8 }, (_, i) => `ref-${i}.png`);
render(<IcLoraPanel {...imageProps} referenceImageFiles={eight} />);
expect(screen.getByText(/Add reference/i).closest('button').disabled).toBe(true);
expect(screen.getByText('8/8')).toBeTruthy();
});

it('disables Remove at the spec minimum so the list cannot go under 2', () => {
render(<IcLoraPanel {...imageProps} referenceImageFiles={['a.png', 'b.png']} />);
for (const i of [1, 2]) {
expect(screen.getByLabelText(`Remove reference ${i}`).disabled).toBe(true);
}
});

it('enables Remove above the minimum', () => {
render(<IcLoraPanel {...imageProps} referenceImageFiles={['a.png', 'b.png', 'c.png']} />);
expect(screen.getByLabelText('Remove reference 1').disabled).toBe(false);
});

it('never warns about resolution (factor 1 imposes no rule)', () => {
// Ingredients' safetensors metadata reports reference_downscale_factor=1, so
// an odd resolution is legal — a copied factor-2 rule would block it for
// nothing.
render(<IcLoraPanel {...imageProps} width={705} height={449} />);
expect(screen.queryByText(/divisible by/i)).toBeNull();
});

it('explains the gated repo + automatic mirror fallback when un-cached', () => {
render(<IcLoraPanel {...imageProps} weightStatus={{ ...imageProps.weightStatus, cached: false, gated: true }} />);
expect(screen.getByText(/falls back to the un-gated mirror/i)).toBeTruthy();
});

it('omits the gated notice once the weight is cached', () => {
render(<IcLoraPanel {...imageProps} weightStatus={{ ...imageProps.weightStatus, cached: true, gated: true }} />);
expect(screen.queryByText(/falls back to the un-gated mirror/i)).toBeNull();
});
});
14 changes: 14 additions & 0 deletions client/src/lib/videoGenParams.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ export const IC_LORA_MODES = [
maxReferences: 1,
referenceKind: 'video',
},
{
mode: 'ic-ingredients',
label: 'Ingredients',
description: 'A scene recomposed from 2-8 reference stills (characters, props, settings)',
uploadLabel: 'Upload a reference still (character / prop / setting)',
// 1 — read off the weight's safetensors metadata server-side; conditions on
// full-resolution references, so no divisibility rule.
referenceDownscaleFactor: 1,
minReferences: 2,
maxReferences: 8,
// Images, not clips: `referenceKind` drives the panel's picker (a 2-8 row
// gallery list rather than the single upload/history pair).
referenceKind: 'image',
},
];

export const IC_LORA_MODE_VALUES = IC_LORA_MODES.map((m) => m.mode);
Expand Down
18 changes: 17 additions & 1 deletion client/src/lib/videoGenParams.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
videoModelMemoryGb, computeFflfSafeFrames, isModelAllowedForMode,
VIDEO_EDGE_BOUNDS, FRAME_OPTIONS, FPS_OPTIONS,
IC_LORA_MODES, IC_LORA_MODE_VALUES, isIcLoraMode, icLoraSpecForMode,
icResolutionIssue,
} from './videoGenParams.js';

describe('videoModelMemoryGb', () => {
Expand Down Expand Up @@ -69,7 +70,7 @@ describe('constants', () => {

describe('IC-LoRA remix modes (#3100)', () => {
it('mirrors the server registry shape', () => {
expect(IC_LORA_MODE_VALUES).toEqual(['ic-control', 'ic-colorize']);
expect(IC_LORA_MODE_VALUES).toEqual(['ic-control', 'ic-colorize', 'ic-ingredients']);
for (const spec of IC_LORA_MODES) {
// The `ic-` prefix drives the download-id router in useModelDownloadStatus.
expect(spec.mode.startsWith('ic-')).toBe(true);
Expand All @@ -78,8 +79,23 @@ describe('IC-LoRA remix modes (#3100)', () => {
// The panel renders these two directly — an empty one ships blank copy.
expect(spec.uploadLabel).toBeTruthy();
expect(spec.description).toBeTruthy();
// Drives which input surface the panel renders (single clip vs the 2-8
// gallery row list), so an unrecognized value would render nothing.
expect(['video', 'image']).toContain(spec.referenceKind);
}
});

it('mirrors the Ingredients bounds + image kind (#3112)', () => {
// The 2-8 count is the weight's contract, mirrored here so the form blocks
// before a POST the route would reject; the parity test in
// server/lib/icLoraWeights.parity.test.js is what keeps the two in step.
const ing = icLoraSpecForMode('ic-ingredients');
expect(ing.minReferences).toBe(2);
expect(ing.maxReferences).toBe(8);
expect(ing.referenceKind).toBe('image');
// Factor 1 → no divisibility rule at all, so an odd resolution is legal.
expect(icResolutionIssue(ing, 705, 449)).toBeNull();
});
it('identifies IC modes', () => {
expect(isIcLoraMode('ic-control')).toBe(true);
expect(isIcLoraMode('ic-colorize')).toBe(true);
Expand Down
Loading