feat(form-graph): port the standalone workflows and align the forms to v1's layout

The last OPEN graph work: the seven standalone (no-ecosystem) workflows —
img2img upscale / remove-background / preprocess, vid2vid upscale /
interpolate, and the two empty panel arms (img2meta, prompt:enhance) —
land as their own arms on a state-only workflowKind dispatch at the root
(the oracle's wire has no such key). Two findings from parity: v1
declares priority/outputFormat at the ROOT gated on image output, so
they apply to standalone image workflows too (the port now matches, 62
generated cases green), and the upscale batch-reset transform is a
correct policy here. Preprocess kind specs import from the v1 module —
they mirror @civitai/client, not the graph engine. Step creation stays
in the submit service keyed on workflow; the dispatcher's input narrows
to EcosystemGenerationData (the arms it actually serves).

The forms catch up to GenerationForm.tsx: every missing family control
(mode, style, movementAmplitude, audioSetting, usePro, turbo,
enableAudio) and the standalone controls (upscaler picker, upscale
dimensions, preprocessor UI, scale/interpolation factors) reuse v1's
input components, and all four per-output forms now follow v1's field
sequence with the same Advanced accordion (AccordionLayout, per-form
persisted open state).

Full suite green: 37,143 tests, ~12,270 differential cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
briant
2026-09-02 16:18:12 -06:00
parent 0ef22a5518
commit e67faa312d
14 changed files with 1713 additions and 800 deletions
+1 -1
View File
@@ -454,7 +454,7 @@ home); full suite + typecheck + lint green; Briant reviews the final diff.
| model3d: polygen (Meshy v6/v7) | DONE | DONE | `polygenVersion` flag-gated (meshyV7Generator) AND workflow-clamped (v7-on-text → v6) in both input and output transforms; v7-only knobs null out per version/image-count; v7 has no seed |
| model3d: tripo / hunyuan3d / pixal3d / trellis2 | DONE | DONE | image-to-3D only; pixal3d and trellis2 are field-identical (one factory, two instances for their own family scopes); hunyuan keeps its `hunyuan*` field prefixes, mapped back in the handler |
| model3d hub | DONE | DONE | ecosystem scoped 'model3d', default PolyGen; the four newer 3D ecosystems are flag-hidden fail-closed via the shared getEcosystemStates |
| standalone workflow graphs (upscale/interpolate/remove-background) | OPEN | OPEN | the last tail — enhancement workflows, not ecosystem families |
| standalone workflows (img2img:upscale / remove-background / preprocess, vid2vid:upscale / interpolate, img2meta, prompt:enhance) | DONE | DONE | seven arms on a state-only `workflowKind` dispatch at the root (the oracle wire has no such key); priority/outputFormat moved to the ROOT gated on image output, matching v1 — they apply to standalone image workflows too. The upscale batch reset (v1 transform) is a `correct`; preprocess kind specs import from the v1 module (they mirror @civitai/client, not the engine). The two empty panels (img2meta, prompt:enhance) are bare graphs. Step creation stays in the submit service keyed on workflow — data parity covers it, no lane handlers needed |
| _…add a row per `*-graph.ts` file during Phase 2 inventory…_ | | | |
---
@@ -1,4 +1,5 @@
import { Checkbox, Input, Stack, Textarea } from '@mantine/core';
import { AccordionLayout } from '~/components/generation_v2/AccordionLayout';
import { Controller } from 'form-graph/react';
import { GenerationTextEditor } from '~/components/Generate/Input/GenerationTextEditor';
@@ -60,6 +61,32 @@ export function AudioGenerationForm() {
</>
)}
/>
<Controller
graph={audioHub}
name="generateCover"
render={({ value, onChange }) => (
<Checkbox
label="Generate cover image"
description="Auto-generate an album cover using AI"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={audioHub}
name="images"
render={({ value, meta, onChange, error }) => (
<ImageUploadMultipleInput
label="Cover image"
value={value}
onChange={onChange}
max={meta?.max}
aspectRatios={meta?.aspectRatios as `${number}:${number}`[] | undefined}
error={error?.message}
/>
)}
/>
<Controller
graph={audioHub}
name="aceAudioMode"
@@ -74,7 +101,6 @@ export function AudioGenerationForm() {
</div>
)}
/>
{/* a separate field from aceAudioMode so each ecosystem keeps its own stored value */}
<Controller
graph={audioHub}
name="minimaxMusicMode"
@@ -156,34 +182,6 @@ export function AudioGenerationForm() {
/>
)}
/>
<Controller
graph={audioHub}
name="cfgScale"
render={({ value, meta, onChange }) => (
<SliderInput
label="CFG Scale"
value={value}
onChange={onChange}
min={meta?.min ?? 0.5}
max={meta?.max ?? 10}
step={meta?.step ?? 0.5}
/>
)}
/>
<Controller
graph={audioHub}
name="steps"
render={({ value, meta, onChange }) => (
<SliderInput
label="Steps"
value={value}
onChange={onChange}
min={meta?.min ?? 1}
max={meta?.max ?? 100}
step={meta?.step ?? 1}
/>
)}
/>
<Controller
graph={audioHub}
name="instrumentalWeight"
@@ -214,32 +212,6 @@ export function AudioGenerationForm() {
/>
)}
/>
<Controller
graph={audioHub}
name="generateCover"
render={({ value, onChange }) => (
<Checkbox
label="Generate cover image"
description="Auto-generate an album cover using AI"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={audioHub}
name="images"
render={({ value, meta, onChange, error }) => (
<ImageUploadMultipleInput
label="Cover image"
value={value}
onChange={onChange}
max={meta?.max}
aspectRatios={meta?.aspectRatios as `${number}:${number}`[] | undefined}
error={error?.message}
/>
)}
/>
<Controller
graph={audioHub}
name="duration"
@@ -254,13 +226,43 @@ export function AudioGenerationForm() {
/>
)}
/>
<Controller
graph={audioHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
<AccordionLayout label="Advanced" storeKey="form-graph-audio-advanced">
<Controller
graph={audioHub}
name="cfgScale"
render={({ value, meta, onChange }) => (
<SliderInput
label="CFG Scale"
value={value}
onChange={onChange}
min={meta?.min ?? 0.5}
max={meta?.max ?? 10}
step={meta?.step ?? 0.5}
/>
)}
/>
<Controller
graph={audioHub}
name="steps"
render={({ value, meta, onChange }) => (
<SliderInput
label="Steps"
value={value}
onChange={onChange}
min={meta?.min ?? 1}
max={meta?.max ?? 100}
step={meta?.step ?? 1}
/>
)}
/>
<Controller
graph={audioHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
</AccordionLayout>
</Stack>
);
}
@@ -1,4 +1,5 @@
import { Checkbox, Input, Stack, Switch } from '@mantine/core';
import { Checkbox, Input, Select, Stack, Switch } from '@mantine/core';
import { AccordionLayout } from '~/components/generation_v2/AccordionLayout';
import { Controller, MultiController } from 'form-graph/react';
import { ActiveWildcards } from '~/components/Generate/Input/ActiveWildcards';
@@ -18,6 +19,14 @@ import { SeedInput } from '~/components/generation_v2/inputs/SeedInput';
import { SelectInput } from '~/components/generation_v2/inputs/SelectInput';
import { SliderInput } from '~/components/generation_v2/inputs/SliderInput';
import { SegmentedControlWrapper } from '~/libs/form/components/SegmentedControlWrapper';
import { PreprocessorExamples } from '~/components/generation_v2/inputs/PreprocessorExamples';
import { UpscaleDimensionsInput } from '~/components/generation_v2/inputs/UpscaleDimensionsInput';
import {
getGroupedPreprocessKindOptions,
getPreprocessKindExamples,
getPreprocessKindInfo,
} from '~/shared/constants/controlnets.constants';
import { generationHub } from '~/shared/form-graph/generation/hub.graph';
import { imageHub } from '~/shared/form-graph/generation/image/hub.graph';
import { ControllerLabel, VersionGroupSelector, useWildcardHandlers } from './form-helpers';
@@ -90,22 +99,6 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
}}
/>
</div>
<Controller
graph={imageHub}
name="images"
render={({ value, meta, onChange, error }) => (
<ImageUploadMultipleInput
label="Source images"
value={value}
onChange={onChange}
max={meta?.max}
slots={meta?.slots}
warnOnMissingAiMetadata={meta?.warnOnMissingAiMetadata}
aspectRatios={meta?.aspectRatios as `${number}:${number}`[] | undefined}
error={error?.message}
/>
)}
/>
<Controller
graph={imageHub}
name="resources"
@@ -128,6 +121,156 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
<ResourceAlerts model={values.model} resources={values.resources} vae={values.vae} />
)}
/>
<Controller
graph={generationHub}
name="preprocessKind"
render={({ value, meta, onChange }) => {
const available = (meta?.options ?? [])
.map((o) => o.value)
.filter((v) => getPreprocessKindExamples(v).length > 0);
const groups = getGroupedPreprocessKindOptions(available);
const ordered = groups.flatMap((g) => g.items.map((i) => i.value));
const cycle = (delta: number) => {
if (!ordered.length) return;
const i = ordered.indexOf(value);
const next = ordered[(i + delta + ordered.length) % ordered.length];
onChange(next as typeof value);
};
return (
<div className="flex flex-col gap-2">
<PreprocessorExamples
examples={getPreprocessKindExamples(value)}
description={getPreprocessKindInfo(value)?.description}
onPrev={() => cycle(-1)}
onNext={() => cycle(1)}
header={
<Select
label="Preprocessor"
description="Choose a control signal, or browse previews with the arrows below."
data={groups}
value={value}
onChange={(v) => v && onChange(v as typeof value)}
allowDeselect={false}
searchable
onFocus={(e) => e.currentTarget.select()}
comboboxProps={{ withinPortal: true }}
/>
}
/>
</div>
);
}}
/>
<Controller
graph={imageHub}
name="images"
render={({ value, meta, onChange, error }) => (
<ImageUploadMultipleInput
label="Source images"
value={value}
onChange={onChange}
max={meta?.max}
slots={meta?.slots}
warnOnMissingAiMetadata={meta?.warnOnMissingAiMetadata}
aspectRatios={meta?.aspectRatios as `${number}:${number}`[] | undefined}
error={error?.message}
/>
)}
/>
<Controller
graph={generationHub}
name="upscaler"
render={({ value, meta, onChange }) => (
<ResourceSelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Upscaler"
info="Select the upscaler model to use for enhancing image resolution."
/>
}
buttonLabel="Select Upscaler"
modalTitle="Select Upscaler"
options={meta?.options}
allowRemove
/>
)}
/>
<Controller
graph={generationHub}
name="upscaleSelection"
render={({ value, meta, onChange }) => (
<UpscaleDimensionsInput value={value} onChange={onChange} meta={meta} />
)}
/>
<Controller
graph={generationHub}
name="preprocessResolution"
render={({ value, meta, onChange }) => (
<SliderInput
label="Resolution"
value={value}
onChange={onChange}
min={meta?.min ?? 64}
max={meta?.max ?? 2048}
step={meta?.step ?? 8}
/>
)}
/>
<Controller
graph={generationHub}
name="kindParams"
render={({ value, meta, onChange }) => {
const specs = meta?.specs;
if (!specs?.length) return null;
const params = value ?? {};
const setParam = (key: string, v: unknown) => onChange({ ...params, [key]: v });
return (
<div className="flex flex-col gap-2">
{specs.map((spec) => {
if (spec.type === 'slider') {
const current = (params[spec.key] as number | undefined) ?? spec.defaultValue;
return (
<SliderInput
key={spec.key}
label={spec.label}
value={current}
onChange={(v) => setParam(spec.key, v)}
min={spec.min}
max={spec.max}
step={spec.step ?? 1}
/>
);
}
if (spec.type === 'boolean') {
const current = (params[spec.key] as boolean | undefined) ?? spec.defaultValue;
return (
<Switch
key={spec.key}
label={spec.label}
checked={current}
onChange={(e) => setParam(spec.key, e.currentTarget.checked)}
/>
);
}
const current = (params[spec.key] as string | undefined) ?? spec.defaultValue;
return (
<Select
key={spec.key}
label={spec.label}
data={spec.options.map((o) => ({ label: o, value: o }))}
value={current}
onChange={(v) => v && setParam(spec.key, v)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
);
})}
</div>
);
}}
/>
<Controller
graph={imageHub}
name="snippets"
@@ -161,6 +304,18 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
/>
)}
/>
<Controller<boolean | undefined, undefined>
name="enablePromptExpansion"
render={({ value, onChange }) => (
<Switch
size="xs"
label="Enhance prompt"
labelPosition="left"
checked={!!value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={imageHub}
name="negativePrompt"
@@ -197,153 +352,6 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
);
}}
/>
<Controller
graph={imageHub}
name="sampler"
render={({ value, meta, onChange }) => (
<SelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Sampler"
info="Each will produce a slightly (or significantly) different result."
/>
}
options={meta?.options}
presets={meta?.presets}
/>
)}
/>
<Controller
graph={imageHub}
name="scheduler"
render={({ value, meta, onChange }) => (
<SelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Scheduler"
info="Controls the noise schedule during generation, affecting quality and style."
/>
}
options={meta?.options}
/>
)}
/>
<Controller
graph={imageHub}
name="cfgScale"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="CFG Scale"
info="Controls how closely the generation follows the text prompt."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="steps"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel label="Steps" info="The number of iterations spent generating." />
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="clipSkip"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label="CLIP Skip"
min={meta.min}
max={meta.max}
step={meta.step}
warning={
value <= 1
? 'Low CLIP Skip values may not work well depending on the model'
: undefined
}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="denoise"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label="Denoise Strength"
min={meta.min}
max={meta.max}
step={meta.step}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="controlNets"
render={({ value, meta, onChange, error }) => (
<ControlNetsInput value={value} onChange={onChange} meta={meta} error={error?.message} />
)}
/>
<Controller
graph={imageHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
<Controller
graph={imageHub}
name="vae"
render={({ value, meta, onChange }) => (
<ResourceSelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="VAE"
info="These provide additional color and detail improvements."
/>
}
buttonLabel="Select VAE"
modalTitle="Select VAE"
options={meta?.options}
allowRemove
/>
)}
/>
<Controller
graph={imageHub}
name="resolution"
@@ -361,51 +369,6 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
</div>
)}
/>
<Controller
graph={imageHub}
name="quality"
render={({ value, meta, onChange }) => (
<SelectInput value={value} onChange={onChange} label="Quality" options={meta.options} />
)}
/>
<Controller
graph={imageHub}
name="transparent"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Transparent Background"
description="Generate image with transparent background"
/>
)}
/>
<Controller
graph={imageHub}
name="enableWebSearch"
render={({ value, onChange }) => (
<Switch
label="Web Search"
description="Enable web search for the image generation task. This will allow the model to use the latest information from the web to generate the image."
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
{/* nested record-less branch (qwen -> qwen3) — key doesn't reach
GraphFieldName yet, so the generic form types this one */}
<Controller<boolean | undefined, undefined>
name="enablePromptExpansion"
render={({ value, onChange }) => (
<Switch
size="xs"
label="Enhance prompt"
labelPosition="left"
checked={!!value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={imageHub}
name="creativity"
@@ -423,45 +386,9 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
</div>
)}
/>
<Controller
graph={imageHub}
name="styleReferences"
render={({ value, meta, onChange, error }) => (
<Krea2StyleReferencesInput
value={value}
onChange={onChange}
meta={meta}
error={error?.message}
/>
)}
/>
<Controller
graph={imageHub}
name="fluxUltraRaw"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Raw Mode"
description="Generate with more natural, less processed look"
/>
)}
/>
<Controller
graph={imageHub}
name="enhancedCompatibility"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Enhanced Compatibility"
description="We've updated our generation engine for better performance, but older prompts may look different. Turn this on to make new generations look more like your originals."
/>
)}
/>
<div className="flex gap-4">
<Controller
graph={imageHub}
graph={generationHub}
name="outputFormat"
render={({ value, meta, onChange }) => (
<OutputFormatInput
@@ -473,7 +400,7 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
)}
/>
<Controller
graph={imageHub}
graph={generationHub}
name="priority"
render={({ value, meta, onChange }) => (
<PriorityInput
@@ -485,6 +412,230 @@ export function ImageGenerationForm({ store }: { store: GenerationStore }) {
)}
/>
</div>
<AccordionLayout label="Advanced" storeKey="form-graph-image-advanced">
<Controller
graph={imageHub}
name="cfgScale"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="CFG Scale"
info="Controls how closely the generation follows the text prompt."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="sampler"
render={({ value, meta, onChange }) => (
<SelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Sampler"
info="Each will produce a slightly (or significantly) different result."
/>
}
options={meta?.options}
presets={meta?.presets}
/>
)}
/>
<Controller
graph={imageHub}
name="scheduler"
render={({ value, meta, onChange }) => (
<SelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Scheduler"
info="Controls the noise schedule during generation, affecting quality and style."
/>
}
options={meta?.options}
/>
)}
/>
<Controller
graph={imageHub}
name="steps"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Steps"
info="The number of iterations spent generating."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
<Controller
graph={imageHub}
name="clipSkip"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label="CLIP Skip"
min={meta.min}
max={meta.max}
step={meta.step}
warning={
value <= 1
? 'Low CLIP Skip values may not work well depending on the model'
: undefined
}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="denoise"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label="Denoise Strength"
min={meta.min}
max={meta.max}
step={meta.step}
/>
) : null
}
/>
<Controller
graph={imageHub}
name="vae"
render={({ value, meta, onChange }) => (
<ResourceSelectInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="VAE"
info="These provide additional color and detail improvements."
/>
}
buttonLabel="Select VAE"
modalTitle="Select VAE"
options={meta?.options}
allowRemove
/>
)}
/>
<Controller
graph={imageHub}
name="enhancedCompatibility"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Enhanced Compatibility"
description="We've updated our generation engine for better performance, but older prompts may look different. Turn this on to make new generations look more like your originals."
/>
)}
/>
<Controller
graph={imageHub}
name="fluxUltraRaw"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Raw Mode"
description="Generate with more natural, less processed look"
/>
)}
/>
<Controller
graph={imageHub}
name="transparent"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Transparent Background"
description="Generate image with transparent background"
/>
)}
/>
<Controller
graph={imageHub}
name="quality"
render={({ value, meta, onChange }) => (
<SelectInput value={value} onChange={onChange} label="Quality" options={meta.options} />
)}
/>
<Controller
graph={imageHub}
name="enableWebSearch"
render={({ value, onChange }) => (
<Switch
label="Web Search"
description="Enable web search for the image generation task. This will allow the model to use the latest information from the web to generate the image."
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={imageHub}
name="controlNets"
render={({ value, meta, onChange, error }) => (
<ControlNetsInput
value={value}
onChange={onChange}
meta={meta}
error={error?.message}
/>
)}
/>
<Controller
graph={imageHub}
name="styleReferences"
render={({ value, meta, onChange, error }) => (
<Krea2StyleReferencesInput
value={value}
onChange={onChange}
meta={meta}
error={error?.message}
/>
)}
/>
</AccordionLayout>
</Stack>
);
}
@@ -1,4 +1,5 @@
import { Checkbox, NumberInput, Stack, Textarea } from '@mantine/core';
import { AccordionLayout } from '~/components/generation_v2/AccordionLayout';
import { Controller } from 'form-graph/react';
import { GenerationTextEditor } from '~/components/Generate/Input/GenerationTextEditor';
@@ -60,9 +61,6 @@ export function Model3dGenerationForm({ store }: { store: GenerationStore }) {
/>
)}
/>
{/* v7 has no text-to-3D: picking it on txt2model3d moves the user to
img2model3d in the same set(); a single flag-less option hides the
control entirely (nothing to choose) */}
<Controller
graph={model3dHub}
name="polygenVersion"
@@ -144,7 +142,102 @@ export function Model3dGenerationForm({ store }: { store: GenerationStore }) {
/>
)}
/>
{/* --- Tripo --- */}
<Controller
graph={model3dHub}
name="shouldTexture"
render={({ value, onChange }) => (
<Checkbox
label="Generate texture"
description="Apply automatic texture to the generated mesh"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="targetPolycount"
render={({ value, meta, onChange }) => (
<SliderInput
label={
<ControllerLabel
label="Target polycount"
info="Final triangle count target. Higher means more detail in the generated mesh."
/>
}
value={value}
onChange={onChange}
min={meta?.min ?? 100}
max={meta?.max ?? 300_000}
step={meta?.step ?? 100}
presets={meta?.presets}
/>
)}
/>
<Controller
graph={model3dHub}
name="topology"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Topology"
info="Choose triangles for hard-surface and game-ready meshes; choose quads for organic shapes and downstream sculpting."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="symmetryMode"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Symmetry"
info="Auto detects bilateral symmetry from the prompt or source image. Use On to force a symmetric mesh, or Off to disable for asymmetric subjects."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="poseMode"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Pose"
info="Force the character into an A-pose or T-pose, which rigs and animates more reliably. Auto lets Meshy keep the pose from your image."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="modelType"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Model type"
info="Low poly produces a stylized, game-ready mesh and ignores the polycount, topology and remesh controls."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="ultraMode"
render={({ value, onChange }) => (
<Checkbox
label="Ultra fidelity"
description="Higher-fidelity geometry with finer surface detail (slower and more expensive)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="texture"
@@ -248,7 +341,6 @@ export function Model3dGenerationForm({ store }: { store: GenerationStore }) {
<SeedInput value={value} onChange={onChange} label="Texture seed" />
)}
/>
{/* --- Hunyuan3D --- */}
<Controller
graph={model3dHub}
name="hunyuanPrompt"
@@ -334,210 +426,111 @@ export function Model3dGenerationForm({ store }: { store: GenerationStore }) {
/>
)}
/>
{/* --- shared Meshy / comfy controls --- */}
<Controller
graph={model3dHub}
name="shouldTexture"
render={({ value, onChange }) => (
<Checkbox
label="Generate texture"
description="Apply automatic texture to the generated mesh"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="targetPolycount"
render={({ value, meta, onChange }) => (
<SliderInput
label={
<ControllerLabel
label="Target polycount"
info="Final triangle count target. Higher means more detail in the generated mesh."
/>
}
value={value}
onChange={onChange}
min={meta?.min ?? 100}
max={meta?.max ?? 300_000}
step={meta?.step ?? 100}
presets={meta?.presets}
/>
)}
/>
<Controller
graph={model3dHub}
name="topology"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Topology"
info="Choose triangles for hard-surface and game-ready meshes; choose quads for organic shapes and downstream sculpting."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="symmetryMode"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Symmetry"
info="Auto detects bilateral symmetry from the prompt or source image. Use On to force a symmetric mesh, or Off to disable for asymmetric subjects."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
{/* --- Meshy v7-only --- */}
<Controller
graph={model3dHub}
name="poseMode"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Pose"
info="Force the character into an A-pose or T-pose, which rigs and animates more reliably. Auto lets Meshy keep the pose from your image."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="modelType"
render={({ value, meta, onChange }) => (
<OptionButtons
label="Model type"
info="Low poly produces a stylized, game-ready mesh and ignores the polycount, topology and remesh controls."
value={value}
options={meta?.options}
onChange={(v) => onChange(v as typeof value)}
/>
)}
/>
<Controller
graph={model3dHub}
name="ultraMode"
render={({ value, onChange }) => (
<Checkbox
label="Ultra fidelity"
description="Higher-fidelity geometry with finer surface detail (slower and more expensive)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
{/* --- advanced --- */}
<Controller
graph={model3dHub}
name="texturePrompt"
render={({ value, meta, onChange }) => (
<Textarea
label={
<ControllerLabel
label="Texture prompt"
info="Optional. Describe the material, finish, or style for the generated texture. Leave blank to let Meshy infer from the main prompt."
/>
}
placeholder={meta?.placeholder ?? 'Weathered oak with bronze fittings…'}
value={value ?? ''}
onChange={(e) => onChange(e.currentTarget.value)}
autosize
minRows={2}
maxLength={meta?.maxLength}
/>
)}
/>
<Controller
graph={model3dHub}
name="shouldRemesh"
render={({ value, onChange }) => (
<Checkbox
label="Remesh"
description="Re-tessellate the mesh for cleaner topology"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="enablePbr"
render={({ value, onChange }) => (
<Checkbox
label="Enable PBR textures"
description="Generate physically-based rendering textures (albedo, normal, roughness)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
{/* one user-facing "Animate" toggle — the submit builder pins
enableRigging = enableAnimation to satisfy the Meshy contract */}
<Controller
graph={model3dHub}
name="enableAnimation"
render={({ value, onChange }) => (
<Checkbox
label="Animate"
description="Generate a rigged, animated mesh (skeleton + idle animation)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="riggingHeightMeters"
render={({ value, meta, onChange }) => (
<SliderInput
label={
<ControllerLabel
label="Character height (m)"
info="Approximate real-world height of the character, used to scale the skeleton."
/>
}
value={value}
onChange={onChange}
min={meta?.min ?? 0.1}
max={meta?.max ?? 10}
step={meta?.step ?? 0.1}
precision={1}
/>
)}
/>
<Controller
graph={model3dHub}
name="animationActionId"
render={({ value, meta, onChange }) => (
<NumberInput
label={
<ControllerLabel
label="Animation preset"
info="Id from Meshy's animation library. 0 is Idle; see https://docs.meshy.ai/en/api/animation-library for the full list."
/>
}
value={value ?? ''}
onChange={(v) => onChange(typeof v === 'number' ? v : undefined)}
min={meta?.min}
max={meta?.max}
placeholder={meta?.placeholder ?? '0 (Idle)'}
allowDecimal={false}
/>
)}
/>
<Controller
graph={model3dHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
<AccordionLayout label="Advanced" storeKey="form-graph-model3d-advanced">
<Controller
graph={model3dHub}
name="texturePrompt"
render={({ value, meta, onChange }) => (
<Textarea
label={
<ControllerLabel
label="Texture prompt"
info="Optional. Describe the material, finish, or style for the generated texture. Leave blank to let Meshy infer from the main prompt."
/>
}
placeholder={meta?.placeholder ?? 'Weathered oak with bronze fittings…'}
value={value ?? ''}
onChange={(e) => onChange(e.currentTarget.value)}
autosize
minRows={2}
maxLength={meta?.maxLength}
/>
)}
/>
<Controller
graph={model3dHub}
name="shouldRemesh"
render={({ value, onChange }) => (
<Checkbox
label="Remesh"
description="Re-tessellate the mesh for cleaner topology"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="enablePbr"
render={({ value, onChange }) => (
<Checkbox
label="Enable PBR textures"
description="Generate physically-based rendering textures (albedo, normal, roughness)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="enableAnimation"
render={({ value, onChange }) => (
<Checkbox
label="Animate"
description="Generate a rigged, animated mesh (skeleton + idle animation)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={model3dHub}
name="riggingHeightMeters"
render={({ value, meta, onChange }) => (
<SliderInput
label={
<ControllerLabel
label="Character height (m)"
info="Approximate real-world height of the character, used to scale the skeleton."
/>
}
value={value}
onChange={onChange}
min={meta?.min ?? 0.1}
max={meta?.max ?? 10}
step={meta?.step ?? 0.1}
precision={1}
/>
)}
/>
<Controller
graph={model3dHub}
name="animationActionId"
render={({ value, meta, onChange }) => (
<NumberInput
label={
<ControllerLabel
label="Animation preset"
info="Id from Meshy's animation library. 0 is Idle; see https://docs.meshy.ai/en/api/animation-library for the full list."
/>
}
value={value ?? ''}
onChange={(v) => onChange(typeof v === 'number' ? v : undefined)}
min={meta?.min}
max={meta?.max}
placeholder={meta?.placeholder ?? '0 (Idle)'}
allowDecimal={false}
/>
)}
/>
<Controller
graph={model3dHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
</AccordionLayout>
</Stack>
);
}
@@ -1,4 +1,5 @@
import { Checkbox, Input, Stack } from '@mantine/core';
import { AccordionLayout } from '~/components/generation_v2/AccordionLayout';
import { Controller, MultiController } from 'form-graph/react';
import { ActiveWildcards } from '~/components/Generate/Input/ActiveWildcards';
@@ -11,10 +12,13 @@ import { ResourceSelectInput } from '~/components/generation_v2/inputs/ResourceS
import { ResourceSelectMultipleInput } from '~/components/generation_v2/inputs/ResourceSelectMultipleInput';
import { SeedInput } from '~/components/generation_v2/inputs/SeedInput';
import { SelectInput } from '~/components/generation_v2/inputs/SelectInput';
import { InterpolationFactorInput } from '~/components/generation_v2/inputs/InterpolationFactorInput';
import { ScaleFactorInput } from '~/components/generation_v2/inputs/ScaleFactorInput';
import { SliderInput } from '~/components/generation_v2/inputs/SliderInput';
import { VideoInput } from '~/components/generation_v2/inputs/VideoInput';
import { ButtonGroupInput } from '~/libs/form/components/ButtonGroupInput';
import { SegmentedControlWrapper } from '~/libs/form/components/SegmentedControlWrapper';
import { generationHub } from '~/shared/form-graph/generation/hub.graph';
import { videoHub } from '~/shared/form-graph/generation/video/hub.graph';
import { wanVersionDefs, wanVersionOptions } from '~/shared/form-graph/generation/video/wan.graph';
@@ -104,6 +108,45 @@ export function VideoGenerationForm({ store }: { store: GenerationStore }) {
)}
/>
</div>
<Controller
graph={videoHub}
name="resources"
render={({ value, meta, onChange }) => (
<ResourceSelectMultipleInput
value={value}
onChange={onChange}
label="Additional Resources"
buttonLabel="Add LoRA"
modalTitle="Select Resources"
options={meta?.options}
limit={meta?.limit}
/>
)}
/>
<MultiController
graph={videoHub}
names={['model', 'resources'] as const}
render={({ values }) => (
<ResourceAlerts model={values.model} resources={values.resources} />
)}
/>
<Controller
graph={videoHub}
name="mode"
render={({ value, meta, onChange }) => (
<div className="flex flex-col gap-1">
<ControllerLabel
label="Mode"
info="Standard mode is faster to generate and more cost-effective. Professional takes longer to generate and has higher quality output."
/>
<SegmentedControlWrapper
value={value}
onChange={(v) => onChange(v as typeof value)}
data={(meta?.options ?? []).map((o) => ({ label: o.label, value: o.value }))}
/>
</div>
)}
/>
<Controller
graph={videoHub}
name="images"
@@ -126,25 +169,29 @@ export function VideoGenerationForm({ store }: { store: GenerationStore }) {
render={({ value, onChange }) => <VideoInput value={value} onChange={onChange} />}
/>
<Controller
graph={videoHub}
name="resources"
graph={generationHub}
name="interpolationFactor"
render={({ value, meta, onChange }) => (
<ResourceSelectMultipleInput
<InterpolationFactorInput
value={value}
onChange={onChange}
label="Additional Resources"
buttonLabel="Add LoRA"
modalTitle="Select Resources"
options={meta?.options}
limit={meta?.limit}
meta={meta}
targetFps={meta?.sourceFps && value ? value * meta.sourceFps : undefined}
/>
)}
/>
<MultiController
graph={videoHub}
names={['model', 'resources'] as const}
render={({ values }) => (
<ResourceAlerts model={values.model} resources={values.resources} />
<Controller
graph={generationHub}
name="scaleFactor"
render={({ value, meta, onChange }) => (
<ScaleFactorInput
value={value}
onChange={onChange}
width={meta?.sourceWidth}
height={meta?.sourceHeight}
maxResolution={meta?.maxOutputResolution}
options={meta?.options}
/>
)}
/>
<Controller
@@ -196,20 +243,6 @@ export function VideoGenerationForm({ store }: { store: GenerationStore }) {
/>
)}
/>
<Controller
graph={videoHub}
name="resolution"
render={({ value, meta, onChange }) => (
<div className="flex flex-col gap-1">
<Input.Label>Resolution</Input.Label>
<SegmentedControlWrapper
value={value}
onChange={(v) => onChange(v as typeof value)}
data={(meta?.options ?? []).map((o) => ({ label: o.label, value: String(o.value) }))}
/>
</div>
)}
/>
<Controller
graph={videoHub}
name="aspectRatio"
@@ -259,132 +292,57 @@ export function VideoGenerationForm({ store }: { store: GenerationStore }) {
/>
<Controller
graph={videoHub}
name="frameGuideStrength"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
name="audioSetting"
render={({ value, meta, onChange }) => (
<div className="flex flex-col gap-1">
<Input.Label>Audio</Input.Label>
<SegmentedControlWrapper
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Frame Guide Strength"
info="Controls how strongly the first/last frame images guide the video generation."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
onChange={(v) => onChange(v as typeof value)}
data={(meta?.options ?? []).map((o) => ({ label: o.label, value: o.value }))}
/>
) : null
}
</div>
)}
/>
<Controller
graph={videoHub}
name="cfgScale"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
name="style"
render={({ value, meta, onChange }) => (
<div className="flex flex-col gap-1">
<Input.Label>Style</Input.Label>
<SegmentedControlWrapper
value={value}
onChange={onChange}
label={
<ControllerLabel
label="CFG Scale"
info="Controls how closely the generation follows the text prompt."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
onChange={(v) => onChange(v as typeof value)}
data={(meta?.options ?? []).map((o) => ({ label: o.label, value: o.value }))}
/>
) : null
}
</div>
)}
/>
<Controller
graph={videoHub}
name="steps"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
name="resolution"
render={({ value, meta, onChange }) => (
<div className="flex flex-col gap-1">
<Input.Label>Resolution</Input.Label>
<SegmentedControlWrapper
value={value}
onChange={onChange}
label={
<ControllerLabel label="Steps" info="The number of iterations spent generating." />
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
onChange={(v) => onChange(v as typeof value)}
data={(meta?.options ?? []).map((o) => ({ label: o.label, value: String(o.value) }))}
/>
) : null
}
</div>
)}
/>
<Controller
graph={videoHub}
name="cannyLowThreshold"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Canny Low Threshold"
info="Lower threshold for Canny edge detection. Lower values detect more edges."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="cannyHighThreshold"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Canny High Threshold"
info="Upper threshold for Canny edge detection. Higher values only keep strong edges."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="guideStrength"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Guide Strength"
info="Controls how closely the output follows the source video structure."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
name="usePrime"
render={({ value, onChange }) => (
<Checkbox
label="Prime"
description="Faster generation for a higher cost. Output quality is unchanged."
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
@@ -402,89 +360,264 @@ export function VideoGenerationForm({ store }: { store: GenerationStore }) {
) : null
}
/>
<Controller
graph={videoHub}
name="shift"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label="Shift"
min={meta.min}
max={meta.max}
step={meta.step}
<AccordionLayout label="Advanced" storeKey="form-graph-video-advanced">
<Controller
graph={videoHub}
name="frameGuideStrength"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Frame Guide Strength"
info="Controls how strongly the first/last frame images guide the video generation."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="cfgScale"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="CFG Scale"
info="Controls how closely the generation follows the text prompt."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="steps"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Steps"
info="The number of iterations spent generating."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="cannyLowThreshold"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Canny Low Threshold"
info="Lower threshold for Canny edge detection. Lower values detect more edges."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="cannyHighThreshold"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Canny High Threshold"
info="Upper threshold for Canny edge detection. Higher values only keep strong edges."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="guideStrength"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label={
<ControllerLabel
label="Guide Strength"
info="Controls how closely the output follows the source video structure."
/>
}
min={meta.min}
max={meta.max}
step={meta.step}
presets={meta.presets}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="movementAmplitude"
render={({ value, meta, onChange }) => (
<div className="flex flex-col gap-1">
<ControllerLabel
label="Movement Amplitude"
info="Control the scale of camera movements and subject actions. Default: Auto (fits most use cases)."
/>
<SegmentedControlWrapper
value={value}
onChange={(v) => onChange(v as typeof value)}
data={(meta?.options ?? []).map((o) => ({ label: o.label, value: o.value }))}
/>
</div>
)}
/>
<Controller
graph={videoHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
<Controller
graph={videoHub}
name="usePro"
render={({ value, onChange }) => (
<Checkbox
label="Pro Mode"
description="Generate with higher quality (uses more credits)"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="interpolatorModel"
render={({ value, meta, onChange }) => (
<SelectInput
value={value}
onChange={(v) => onChange(v as typeof value)}
label="Interpolator"
options={meta?.options}
/>
)}
/>
<Controller
graph={videoHub}
name="usePrime"
render={({ value, onChange }) => (
<Checkbox
label="Prime"
description="Faster generation for a higher cost. Output quality is unchanged."
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="draft"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Draft Mode"
description="Generate faster at with optimized settings (may reduce quality)"
/>
)}
/>
<Controller
graph={videoHub}
name="enablePromptEnhancer"
render={({ value, onChange }) => (
<Checkbox
label="Enhance prompt"
description="Automatically improve your prompt for better results"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="generateAudio"
render={({ value, onChange }) => (
<Checkbox
label="Generate audio"
description="Generate audio along with the video"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="seed"
render={({ value, onChange }) => (
<SeedInput value={value} onChange={onChange} label="Seed" />
)}
/>
)}
/>
<Controller
graph={videoHub}
name="enablePromptEnhancer"
render={({ value, onChange }) => (
<Checkbox
label="Enhance prompt"
description="Automatically improve your prompt for better results"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="generateAudio"
render={({ value, onChange }) => (
<Checkbox
label="Generate audio"
description="Generate audio along with the video"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="turbo"
render={({ value, onChange }) => (
<Checkbox
label="Turbo"
description="Use the turbo LoRA — converges in fewer steps"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="enableAudio"
render={({ value, onChange }) => (
<Checkbox
label="Generate audio"
description="Generate audio along with the video"
checked={value}
onChange={(e) => onChange(e.currentTarget.checked)}
/>
)}
/>
<Controller
graph={videoHub}
name="draft"
render={({ value, onChange }) => (
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
label="Draft Mode"
description="Generate faster at with optimized settings (may reduce quality)"
/>
)}
/>
<Controller
graph={videoHub}
name="shift"
render={({ value, meta, onChange }) =>
meta ? (
<SliderInput
value={value}
onChange={onChange}
label="Shift"
min={meta.min}
max={meta.max}
step={meta.step}
/>
) : null
}
/>
<Controller
graph={videoHub}
name="interpolatorModel"
render={({ value, meta, onChange }) => (
<SelectInput
value={value}
onChange={(v) => onChange(v as typeof value)}
label="Interpolator"
options={meta?.options}
/>
)}
/>
</AccordionLayout>
</Stack>
);
}
@@ -60,9 +60,9 @@ import { createStableDiffusionInput } from './stable-diffusion.handler';
import { createWanSteps } from './wan.handler';
import type { WanGenerationData } from './wan.handler';
import { createZImageInput } from './z-image.handler';
import type { GenerationData, LooseGenerationData } from './types';
import type { EcosystemGenerationData, GenerationData, LooseGenerationData } from './types';
export type { GenerationData, LooseGenerationData } from './types';
export type { EcosystemGenerationData, GenerationData, LooseGenerationData } from './types';
export { createChromaInput } from './chroma.handler';
export { createFluxInput } from './flux.handler';
export { createFluxKontextInput } from './flux-kontext.handler';
@@ -111,7 +111,7 @@ export { createWanSteps } from './wan.handler';
export { createZImageInput } from './z-image.handler';
export async function createFormGraphStepInput(
data: GenerationData,
data: EcosystemGenerationData,
handlerCtx: GenerationHandlerCtx
): Promise<StepInput[]> {
const normalizedData = withSeed(data);
@@ -149,7 +149,7 @@ function withSeed<T extends object>(data: T): T {
}
function createStep(
data: GenerationData,
data: EcosystemGenerationData,
handlerCtx: GenerationHandlerCtx
): Promise<StepInput[]> | StepInput[] {
if (isWanEcosystem(data.ecosystem)) return createWanSteps(data as WanGenerationData, handlerCtx);
@@ -11,11 +11,18 @@ import type { generationHub } from '~/shared/form-graph/generation/hub.graph';
export type GenerationData = InferData<typeof generationHub>;
/**
* One (or several) family arms, selected by the ecosystem discriminant the
* hub's branchOn keys type every arm's ecosystem as a literal, so this is
* the typed view a handler declares instead of narrowing a loose bag by hand.
* The arms the ECOSYSTEM dispatcher serves the root union minus the
* standalone workflow arms (upscale/interpolate/remove-background/), whose
* step creation lives in the submit service, keyed on the workflow.
*/
export type EcosystemData<E extends GenerationData['ecosystem']> = InferArm<
export type EcosystemGenerationData = Extract<GenerationData, { ecosystem: string }>;
/**
* One (or several) family arms, selected by the ecosystem discriminant the
* hub tables type every arm's ecosystem as a literal, so this is the typed
* view a handler declares instead of narrowing a loose bag by hand.
*/
export type EcosystemData<E extends EcosystemGenerationData['ecosystem']> = InferArm<
typeof generationHub,
'ecosystem',
E
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import { assertDifferential, runOracle, type AnyRecord } from './differential';
import { generationHub } from '../hub.graph';
import { reconcileSelectors } from '../reconcile';
import type { GenerationCtx } from '~/shared/data-graph/generation/context';
/**
* Differential parity for the STANDALONE workflows the seven arms v1's
* root discriminator serves outside the ecosystem graph (enhancements plus
* the two empty no-submit panels). No ecosystem, so the matrix is
* workflow x input shape x context.
*/
const BASE: GenerationCtx = {
limits: { maxQuantity: 4, maxResources: 9, vidQuantity: 4 },
user: { isMember: true, tier: 'gold' },
flags: {},
gateRules: [],
};
const CONTEXTS: [string, GenerationCtx][] = [
['base', BASE],
[
'freeTier',
{
...BASE,
user: { isMember: false, tier: 'free' },
limits: { maxQuantity: 1, maxResources: 1, vidQuantity: 1 },
},
],
];
const IMG = (w: number, h: number) => ({ url: 'https://example.com/a.png', width: w, height: h });
const VID = (w: number, h: number, fps: number) => ({
url: 'https://example.com/a.mp4',
metadata: { width: w, height: h, fps, duration: 5 },
});
const CASES: Array<{ workflow: string; shapes: AnyRecord[] }> = [
{
workflow: 'img2img:upscale',
shapes: [
{},
{ images: [IMG(512, 512)] },
{ images: [IMG(512, 512), IMG(1024, 768)] },
// multiplier selection, incl. one useless-for-batch (resets to default)
{ images: [IMG(512, 512)], upscaleSelection: { type: 'multiplier', multiplier: 2 } },
{ images: [IMG(3000, 3000)], upscaleSelection: { type: 'multiplier', multiplier: 3 } },
{ images: [IMG(512, 512)], upscaleSelection: { type: 'resolution', resolution: 2048 } },
// resolution useless (image already above target)
{ images: [IMG(3900, 3900)], upscaleSelection: { type: 'resolution', resolution: 2048 } },
{ images: [IMG(512, 512)], upscaler: { id: 147759, model: { type: 'Upscaler' } } },
// over the batch cap
{ images: Array.from({ length: 12 }, () => IMG(256, 256)) },
],
},
{
workflow: 'img2img:remove-background',
shapes: [{}, { images: [IMG(512, 512)] }, { images: [IMG(512, 512), IMG(256, 256)] }],
},
{
workflow: 'img2img:preprocess',
shapes: [
{},
{ images: [IMG(512, 512)] },
{ images: [IMG(512, 512)], preprocessKind: 'openpose' },
{
images: [IMG(512, 512)],
preprocessKind: 'canny',
preprocessResolution: 1024,
kindParams: { lowThreshold: 50, highThreshold: 150 },
},
{ images: [IMG(512, 512)], preprocessKind: 'not-a-kind' },
],
},
{
workflow: 'vid2vid:upscale',
shapes: [
{},
{ video: VID(640, 480, 24) },
{ video: VID(640, 480, 24), scaleFactor: 3 },
// x3 would exceed the 2560px ceiling
{ video: VID(1280, 720, 30), scaleFactor: 3 },
{ video: 'https://example.com/plain-url.mp4' },
],
},
{
workflow: 'vid2vid:interpolate',
shapes: [
{},
{ video: VID(640, 480, 24) },
{ video: VID(640, 480, 24), interpolationFactor: 4 },
// 4x60 exceeds the 120fps ceiling
{ video: VID(640, 480, 60), interpolationFactor: 4 },
],
},
{ workflow: 'img2meta', shapes: [{}, { images: [IMG(512, 512)] }] },
{ workflow: 'prompt:enhance', shapes: [{}, { prompt: 'a cat' }] },
];
const port = {
parse: (raw: AnyRecord, ext: never) => generationHub.parse(reconcileSelectors(raw).raw, ext),
};
type Combo = { name: string; input: AnyRecord; ext: GenerationCtx };
const COMBOS: Combo[] = [];
for (const [ctxName, ctx] of CONTEXTS) {
for (const { workflow, shapes } of CASES) {
for (const [i, shape] of shapes.entries()) {
COMBOS.push({
name: `${ctxName} | ${workflow} | shape${i}`,
input: { workflow, ...shape },
ext: ctx,
});
}
}
}
describe('standalone workflows: differential parity with generationGraph', () => {
it('covers every standalone workflow', () => {
expect(COMBOS.length).toBeGreaterThan(50);
const covered = new Set(COMBOS.map((c) => c.input.workflow));
expect(CASES.map((c) => c.workflow).filter((w) => !covered.has(w))).toEqual([]);
});
it.each(COMBOS)('$name', ({ input, ext }) => {
assertDifferential(port, { name: JSON.stringify(input), input }, ext);
});
it('sanity: the oracle serves the standalone arms (guards the whole matrix)', () => {
const upscale = runOracle({ workflow: 'img2img:upscale', images: [IMG(512, 512)] }, BASE);
expect(upscale.success).toBe(true);
});
});
+1 -1
View File
@@ -197,7 +197,7 @@ export const resourceSchema = z.object({
});
export type ResourceData = z.infer<typeof resourceSchema>;
const resourceInputSchema = z.union([
export const resourceInputSchema = z.union([
z.number().transform((id) => ({ id })),
z.looseObject({ id: z.number() }),
]);
+92 -2
View File
@@ -12,7 +12,14 @@ import { imageHub } from './image/hub.graph';
import { videoHub } from './video/hub.graph';
import { audioHub } from './audio/hub.graph';
import { model3dHub } from './model3d/hub.graph';
import type { RootCtx } from './shared';
import { imageUpscale } from './workflows/image-upscale.graph';
import {
imagePreprocess,
imageRemoveBackground,
metadataExtraction,
promptEnhancement,
} from './workflows/image-simple.graph';
import { videoInterpolation, videoUpscale } from './workflows/video-enhance.graph';
/**
* The composed root, mirroring `generation-graph.ts`'s head: workflow (key
@@ -24,6 +31,10 @@ import type { RootCtx } from './shared';
// ---- copied from generation-graph.ts, which dies with the data-graph engine
// Copied from generation-graph.ts, which dies with the data-graph engine.
const priorityOptions = ['low', 'normal', 'high'] as const;
const outputFormatOptions = ['jpeg', 'png'] as const;
/** Maps new-format workflow keys back to old format for migration */
const NEW_TO_OLD: Record<string, string> = {
'image:create': 'txt2img',
@@ -65,6 +76,31 @@ const outputHubs = branch('output', [
[['model3d'], model3dHub],
] as const);
/**
* The standalone (no-ecosystem) workflows, each its own branch in v1's root
* discriminator. Everything else routes to the per-output ecosystem hubs.
*/
const STANDALONE_WORKFLOWS = new Set([
'vid2vid:interpolate',
'vid2vid:upscale',
'img2img:upscale',
'img2img:remove-background',
'img2img:preprocess',
'img2meta',
'prompt:enhance',
]);
const workflowKinds = branch('workflowKind', [
[['vid2vid:interpolate'], videoInterpolation],
[['vid2vid:upscale'], videoUpscale],
[['img2img:upscale'], imageUpscale],
[['img2img:remove-background'], imageRemoveBackground],
[['img2img:preprocess'], imagePreprocess],
[['img2meta'], metadataExtraction],
[['prompt:enhance'], promptEnhancement],
[['ecosystem'], outputHubs],
] as const);
export const generationHub = defineGraph<GenerationCtx>()
.field('workflow', ({ _ext }) => {
const { hidden, states } = mergeGateStates(
@@ -86,6 +122,60 @@ export const generationHub = defineGraph<GenerationCtx>()
})
.computed('output', ({ workflow }) => getOutputTypeForWorkflow(workflow))
.computed('input', ({ workflow }) => getInputTypeForWorkflow(workflow))
.use(outputHubs);
// v1 declares priority/outputFormat at the ROOT gated on image output, so
// they apply to the standalone image workflows too, not just the image hub
.field('priority', ({ output, _ext }) => {
if (output !== 'image') return null;
const isMember = _ext.user?.isMember ?? false;
const options: {
label: string;
value: (typeof priorityOptions)[number];
offset: number;
lineThrough?: boolean;
memberOnly?: boolean;
}[] = isMember
? [
{ label: 'High', value: 'low', offset: 10, lineThrough: true },
{ label: 'Highest', value: 'high', offset: 20 },
]
: [
{ label: 'Standard', value: 'low', offset: 0 },
{ label: 'High', value: 'normal', offset: 10 },
{ label: 'Highest', value: 'high', offset: 20, memberOnly: true },
];
return {
input: z
.enum(priorityOptions)
.optional()
.transform((val) => (!isMember && val === 'high' ? ('low' as const) : val)),
output: z.enum(priorityOptions),
default: 'low' as const,
meta: { options, isMember },
};
})
.field('outputFormat', ({ output, workflow, _ext }) =>
output !== 'image' || workflow === 'img2img:remove-background'
? null
: {
input: z.enum(outputFormatOptions).optional(),
output: z.enum(outputFormatOptions),
default: 'jpeg' as const,
meta: {
options: [
{ label: 'JPEG', value: 'jpeg' as const, offset: 0 },
{ label: 'PNG', value: 'png' as const, offset: 2 },
],
isMember: _ext.user?.isMember ?? false,
},
}
)
// state-only (the oracle's wire has no such key): standalone workflows get
// their own arm; everything else rides the per-output ecosystem hubs
.computed(
'workflowKind',
({ workflow }) => (STANDALONE_WORKFLOWS.has(workflow) ? workflow : 'ecosystem'),
{ emit: false }
)
.use(workflowKinds);
export type GenerationState = ReturnType<typeof generationHub.resolve>;
@@ -39,17 +39,12 @@ import { zimage } from './zimage.graph';
/**
* The IMAGE hub: ecosystem selection scoped to image output, the image-only
* the family dispatch (a keyed branch whose table types each arm), the
* image-only head fields (`priority`, `outputFormat`), and the two
* the family dispatch (a keyed branch whose table types each arm) and the two
* fields the oracle declares AFTER its family discriminator because they read
* family state (`enhancedCompatibility` reads the model; `quantity` reads
* both). Workflow and the output/input computeds live on the root (`../hub.ts`).
*/
// Copied from generation-graph.ts, which dies with the data-graph engine.
const priorityOptions = ['low', 'normal', 'high'] as const;
const outputFormatOptions = ['jpeg', 'png'] as const;
export const imageHub = defineGraph<RootCtx>()
.field('ecosystem', ({ _ext }) => {
const { compatibleEcosystems, hiddenEcosystems, ecosystemStates } = getEcosystemStates(
@@ -128,50 +123,6 @@ export const imageHub = defineGraph<RootCtx>()
[['Grok'], grokImage],
] as const)
)
.field('priority', ({ _ext }) => {
const isMember = _ext.user?.isMember ?? false;
const options: {
label: string;
value: (typeof priorityOptions)[number];
offset: number;
lineThrough?: boolean;
memberOnly?: boolean;
}[] = isMember
? [
{ label: 'High', value: 'low', offset: 10, lineThrough: true },
{ label: 'Highest', value: 'high', offset: 20 },
]
: [
{ label: 'Standard', value: 'low', offset: 0 },
{ label: 'High', value: 'normal', offset: 10 },
{ label: 'Highest', value: 'high', offset: 20, memberOnly: true },
];
return {
input: z
.enum(priorityOptions)
.optional()
.transform((val) => (!isMember && val === 'high' ? ('low' as const) : val)),
output: z.enum(priorityOptions),
default: 'low' as const,
meta: { options, isMember },
};
})
.field('outputFormat', ({ _ext }) =>
_ext.workflow === 'img2img:remove-background'
? null
: {
input: z.enum(outputFormatOptions).optional(),
output: z.enum(outputFormatOptions),
default: 'jpeg' as const,
meta: {
options: [
{ label: 'JPEG', value: 'jpeg' as const, offset: 0 },
{ label: 'PNG', value: 'png' as const, offset: 2 },
],
isMember: _ext.user?.isMember ?? false,
},
}
)
// Both read the family's DERIVED ecosystem where one exists (v1 reads its
// conflated key after the checkpoint effect has moved it); families without
// a derivation declare nothing and the selection stands.
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { defineGraph } from 'form-graph';
import type { GenerationCtx } from '~/shared/data-graph/generation/context';
import {
preprocessKindParamSpecs,
preprocessKinds,
type PreprocessKind,
} from '~/shared/data-graph/generation/image-preprocess-graph';
import { imagesDef, sliderDef } from '../defs';
/**
* The remaining standalone image workflows, ported from
* `image-remove-background-graph.ts` and `image-preprocess-graph.ts`, plus
* the two EMPTY graphs (`img2meta`, `prompt:enhance`) whose UIs are fully
* self-contained panels they exist so every workflow value has an arm.
*
* The preprocess kind list and per-kind param specs are imported from the v1
* module: they mirror the @civitai/client `PreprocessImageInput` union, not
* the graph engine, and duplicating 36 kinds' specs would only drift.
*/
export const imageRemoveBackground = defineGraph<GenerationCtx>().field('images', imagesDef({}));
const kindParamsSchema = z.record(z.string(), z.unknown());
export const imagePreprocess = defineGraph<GenerationCtx>()
.field('images', imagesDef({ min: 1, max: 1 }))
.field('preprocessKind', {
input: z.enum(preprocessKinds).optional(),
output: z.enum(preprocessKinds),
default: 'canny' as PreprocessKind,
meta: { options: preprocessKinds.map((value) => ({ label: value, value })) },
})
.field('preprocessResolution', sliderDef({ min: 64, max: 2048, step: 8, default: 512 }))
.field('kindParams', ({ preprocessKind }) => ({
input: kindParamsSchema.optional(),
output: kindParamsSchema,
default: {} as Record<string, unknown>,
meta: {
specs: preprocessKind ? preprocessKindParamSpecs[preprocessKind] : [],
},
}));
export const metadataExtraction = defineGraph<GenerationCtx>();
export const promptEnhancement = defineGraph<GenerationCtx>();
@@ -0,0 +1,293 @@
import { z } from 'zod';
import { defineGraph } from 'form-graph';
import type { GenerationCtx } from '~/shared/data-graph/generation/context';
import { imagesDef, resourceSchema, resourceInputSchema, type ResourceData } from '../defs';
/**
* Image upscale (img2img:upscale), ported from `image-upscale-graph.ts`.
* Standalone no ecosystem. Batch of up to 10 images; each image is
* adaptively assigned the best usable multiplier, and the selection resets
* to the default when the current one stops being useful for every image in
* the batch (v1's transform, here a `correct` policy).
*/
// ---- copied from image-upscale-graph.ts, which dies with the data-graph engine
const MAX_OUTPUT_RESOLUTION = 4096;
const MAX_UPSCALE_IMAGES = 10;
const UPSCALE_MULTIPLIERS = [1.5, 2, 2.5, 3] as const;
const UPSCALE_RESOLUTIONS = [
{ label: '2K', value: 2048 },
{ label: '4K', value: 3840 },
] as const;
export type UpscaleSelection =
| { type: 'multiplier'; multiplier: number }
| { type: 'resolution'; resolution: number };
export interface UpscaleMultiplierOption {
label: string;
multiplier: number;
width: number;
height: number;
disabled: boolean;
}
export interface UpscaleResolutionOption {
label: string;
resolution: number;
width: number;
height: number;
disabled: boolean;
}
export interface ImageAnnotation {
label: string;
color: 'green' | 'yellow' | 'red';
tooltip?: string;
}
export interface UpscaleSelectionMeta {
sourceWidth: number | undefined;
sourceHeight: number | undefined;
maxOutputResolution: number;
multiplierOptions: UpscaleMultiplierOption[];
resolutionOptions: UpscaleResolutionOption[];
canUpscale: boolean;
imageCount: number;
}
function computeUpscaleDimensions(
sourceWidth: number,
sourceHeight: number,
target: number
): { width: number; height: number } {
const aspectRatio = sourceWidth / sourceHeight;
let width: number;
let height: number;
if (sourceWidth >= sourceHeight) {
width = target;
height = Math.round(target / aspectRatio);
} else {
width = Math.round(target * aspectRatio);
height = target;
}
return {
width: Math.ceil(width / 64) * 64,
height: Math.ceil(height / 64) * 64,
};
}
function isMultiplierValid(sourceWidth: number, sourceHeight: number, multiplier: number): boolean {
const target = Math.max(sourceWidth, sourceHeight) * multiplier;
const dims = computeUpscaleDimensions(sourceWidth, sourceHeight, target);
return Math.max(dims.width, dims.height) <= MAX_OUTPUT_RESOLUTION;
}
function findEffectiveMultiplier(
sourceWidth: number,
sourceHeight: number,
selectedMultiplier: number
): number | null {
const candidates = [...UPSCALE_MULTIPLIERS]
.filter((m) => m <= selectedMultiplier)
.sort((a, b) => b - a);
for (const multiplier of candidates) {
if (isMultiplierValid(sourceWidth, sourceHeight, multiplier)) {
return multiplier;
}
}
return null;
}
const upscaleSelectionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('multiplier'), multiplier: z.number() }),
z.object({ type: z.literal('resolution'), resolution: z.number() }),
]);
// ---- end of image-upscale-graph.ts copies -----------------------------------
/** v1's `upscalerNode`: an Upscaler-type resource with a pinned default. */
const UPSCALER = {
input: resourceInputSchema.optional(),
output: resourceSchema,
default: { id: 164821, model: { type: 'Upscaler' } } as ResourceData,
meta: (value: ResourceData | undefined) => ({
options: {
canGenerate: true,
resources: [{ type: 'Upscaler' }],
excludeIds: value ? [value.id] : [],
},
}),
};
export const imageUpscale = defineGraph<GenerationCtx>()
.field('images', imagesDef({ min: 1, max: MAX_UPSCALE_IMAGES }))
.field('upscaler', UPSCALER)
.field('upscaleSelection', ({ images }) => {
const batch = images ?? [];
const firstImage = batch[0];
const sourceWidth = firstImage?.width;
const sourceHeight = firstImage?.height;
const multiplierOptions: UpscaleMultiplierOption[] =
sourceWidth && sourceHeight
? UPSCALE_MULTIPLIERS.map((multiplier) => {
const target = Math.max(sourceWidth, sourceHeight) * multiplier;
const dims = computeUpscaleDimensions(sourceWidth, sourceHeight, target);
const disabled =
batch.length > 0
? batch.every((img) => {
if (!img.width || !img.height) return true;
return !isMultiplierValid(img.width, img.height, multiplier);
})
: true;
return { label: `x${multiplier}`, multiplier, ...dims, disabled };
})
: [];
const resolutionOptions: UpscaleResolutionOption[] =
sourceWidth && sourceHeight
? UPSCALE_RESOLUTIONS.map(({ label, value: targetRes }) => {
const dims = computeUpscaleDimensions(sourceWidth, sourceHeight, targetRes);
const disabled =
batch.length > 0
? batch.every((img) => {
if (!img.width || !img.height) return true;
const maxDim = Math.max(img.width, img.height);
if (maxDim >= targetRes) return true;
const imgDims = computeUpscaleDimensions(img.width, img.height, targetRes);
return Math.max(imgDims.width, imgDims.height) > MAX_OUTPUT_RESOLUTION;
})
: true;
return { label, resolution: targetRes, ...dims, disabled };
})
: [];
const defaultMultiplier = multiplierOptions.find((o) => !o.disabled);
const defaultValue: UpscaleSelection | undefined = defaultMultiplier
? { type: 'multiplier', multiplier: defaultMultiplier.multiplier }
: undefined;
const canUpscale =
multiplierOptions.some((o) => !o.disabled) || resolutionOptions.some((o) => !o.disabled);
/** Is the selection still useful for at least one image in the batch? */
const isUseful = (value: UpscaleSelection): boolean => {
if (batch.length === 0) return true;
if (value.type === 'multiplier') {
return batch.some(
(img) =>
img.width &&
img.height &&
findEffectiveMultiplier(img.width, img.height, value.multiplier) !== null
);
}
return batch.some((img) => {
if (!img.width || !img.height) return false;
const maxDim = Math.max(img.width, img.height);
if (maxDim >= value.resolution) return false;
const dims = computeUpscaleDimensions(img.width, img.height, value.resolution);
return Math.max(dims.width, dims.height) <= MAX_OUTPUT_RESOLUTION;
});
};
return {
input: upscaleSelectionSchema.optional(),
output: upscaleSelectionSchema,
default: defaultValue,
// v1's transform: a selection that stopped being useful for every image
// in the batch resets to the first available option
correct: (value: UpscaleSelection) =>
defaultValue && !isUseful(value)
? { value: defaultValue, reason: 'selection_useless_for_batch' }
: undefined,
meta: {
sourceWidth,
sourceHeight,
maxOutputResolution: MAX_OUTPUT_RESOLUTION,
multiplierOptions,
resolutionOptions,
canUpscale,
imageCount: batch.length,
} satisfies UpscaleSelectionMeta,
};
})
.computed('targetDimensions', ({ images, upscaleSelection }) => {
const batch = images ?? [];
const selection = upscaleSelection;
if (!selection || batch.length === 0) return [];
return batch.map(
(image): { width: number; height: number; effectiveMultiplier: number } | null => {
if (!image.width || !image.height) return null;
if (selection.type === 'resolution') {
const maxDim = Math.max(image.width, image.height);
if (maxDim >= selection.resolution) return null;
const dims = computeUpscaleDimensions(image.width, image.height, selection.resolution);
if (Math.max(dims.width, dims.height) > MAX_OUTPUT_RESOLUTION) return null;
const effectiveMultiplier =
Math.max(dims.width, dims.height) / Math.max(image.width, image.height);
return { ...dims, effectiveMultiplier };
}
const effective = findEffectiveMultiplier(image.width, image.height, selection.multiplier);
if (effective === null) return null;
const target = Math.max(image.width, image.height) * effective;
const dims = computeUpscaleDimensions(image.width, image.height, target);
return { ...dims, effectiveMultiplier: effective };
}
);
})
.computed('annotations', ({ images, upscaleSelection }): (ImageAnnotation | null)[] => {
const batch = images ?? [];
const selection = upscaleSelection;
if (!selection || batch.length === 0) return [];
return batch.map((image) => {
if (!image.width || !image.height) {
return { label: 'No dims', color: 'red' as const, tooltip: 'Missing dimensions' };
}
if (selection.type === 'resolution') {
const maxDim = Math.max(image.width, image.height);
if (maxDim >= selection.resolution) {
return {
label: 'Excluded',
color: 'red' as const,
tooltip: `Already at or above ${selection.resolution}px`,
};
}
const dims = computeUpscaleDimensions(image.width, image.height, selection.resolution);
if (Math.max(dims.width, dims.height) > MAX_OUTPUT_RESOLUTION) {
return {
label: 'Excluded',
color: 'red' as const,
tooltip: `Would exceed ${MAX_OUTPUT_RESOLUTION}px max`,
};
}
return { label: `${dims.width}x${dims.height}`, color: 'green' as const };
}
const effective = findEffectiveMultiplier(image.width, image.height, selection.multiplier);
if (effective === null) {
return {
label: 'Excluded',
color: 'red' as const,
tooltip: 'No valid multiplier — image too large',
};
}
if (effective < selection.multiplier) {
return {
label: `x${effective}`,
color: 'yellow' as const,
tooltip: `Downgraded from x${selection.multiplier} to x${effective}`,
};
}
return { label: `x${effective}`, color: 'green' as const };
});
});
@@ -0,0 +1,112 @@
import { z } from 'zod';
import { defineGraph } from 'form-graph';
import type { GenerationCtx } from '~/shared/data-graph/generation/context';
import { VIDEO } from '../defs';
/**
* The video enhancement workflows, ported from `video-upscale-graph.ts` and
* `video-interpolation-graph.ts`. Standalone no ecosystem; they operate on
* an existing video, and the option sets derive from its metadata.
*/
// ---- copied from the v1 graphs, which die with the data-graph engine --------
const MAX_OUTPUT_RESOLUTION = 2560;
const UPSCALE_MULTIPLIERS = [2, 3] as const;
const MAX_OUTPUT_FPS = 120;
const INTERPOLATION_MULTIPLIERS = [2, 3, 4] as const;
export type InterpolationOption = {
value: number;
label: string;
disabled: boolean;
targetFps: number;
};
export type ScaleFactorOption = {
value: number;
label: string;
disabled: boolean;
targetWidth: number;
targetHeight: number;
};
// ---- end of v1 copies -------------------------------------------------------
export const videoUpscale = defineGraph<GenerationCtx>()
.field('video', VIDEO)
.field('scaleFactor', ({ video }) => {
const width = video?.metadata?.width;
const height = video?.metadata?.height;
const maxDimension = width && height ? Math.max(width, height) : undefined;
const options: ScaleFactorOption[] = UPSCALE_MULTIPLIERS.map((multiplier) => ({
value: multiplier,
label: `x${multiplier}`,
disabled: maxDimension ? multiplier * maxDimension > MAX_OUTPUT_RESOLUTION : false,
targetWidth: width ? multiplier * width : 0,
targetHeight: height ? multiplier * height : 0,
}));
const defaultValue = options.find((o) => !o.disabled)?.value ?? UPSCALE_MULTIPLIERS[0];
const canUpscale = maxDimension
? maxDimension * Math.min(...UPSCALE_MULTIPLIERS) <= MAX_OUTPUT_RESOLUTION
: true;
const min = Math.min(...UPSCALE_MULTIPLIERS);
const max = Math.max(...UPSCALE_MULTIPLIERS);
return {
input: z.coerce.number().int().min(min).max(max).optional(),
output: z
.number()
.int()
.min(min)
.max(max)
.refine((val) => !maxDimension || val * maxDimension <= MAX_OUTPUT_RESOLUTION, {
message: `Scale factor would exceed maximum output resolution of ${MAX_OUTPUT_RESOLUTION}px`,
}),
default: defaultValue,
meta: {
options,
canUpscale,
sourceWidth: width,
sourceHeight: height,
maxOutputResolution: MAX_OUTPUT_RESOLUTION,
},
};
})
.computed('targetDimensions', ({ video, scaleFactor }) => {
const width = video?.metadata?.width;
const height = video?.metadata?.height;
if (!width || !height) return undefined;
return { width: scaleFactor * width, height: scaleFactor * height };
});
export const videoInterpolation = defineGraph<GenerationCtx>()
.field('video', VIDEO)
.field('interpolationFactor', ({ video }) => {
const fps = video?.metadata?.fps;
const options: InterpolationOption[] = INTERPOLATION_MULTIPLIERS.map((multiplier) => ({
value: multiplier,
label: `x${multiplier}`,
disabled: fps ? multiplier * fps > MAX_OUTPUT_FPS : false,
targetFps: fps ? multiplier * fps : 0,
}));
const defaultValue = options.find((o) => !o.disabled)?.value ?? INTERPOLATION_MULTIPLIERS[0];
const canInterpolate = fps
? fps * Math.min(...INTERPOLATION_MULTIPLIERS) <= MAX_OUTPUT_FPS
: true;
return {
input: z.coerce.number().int().min(2).max(4).optional(),
output: z.number().int().min(2).max(4),
default: defaultValue,
meta: { options, canInterpolate, sourceFps: fps, maxOutputFps: MAX_OUTPUT_FPS },
};
})
.computed('targetFps', ({ video, interpolationFactor }) => {
const fps = video?.metadata?.fps;
if (!fps) return undefined;
return interpolationFactor * fps;
});