mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() }),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user