Browse Source

高清放大

feature/0710
yun 4 days ago
parent
commit
f1b47d5364
  1. 4
      src/components/MyCreationPickerModal.tsx
  2. 247
      src/components/composer/NodeHighDefinitionPanel.tsx
  3. 13
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  4. 33
      src/utils/__tests__/highDefinitionNodes.test.ts
  5. 13
      src/utils/highDefinitionNodes.ts

4
src/components/MyCreationPickerModal.tsx

@ -170,7 +170,7 @@ function getHistoryMediaUrl(item: HistoryItem, kind: HistoryTabKey): string | nu
const mediaKind = getHistoryKind(item, kind); const mediaKind = getHistoryKind(item, kind);
const result = firstResult(item); const result = firstResult(item);
if (mediaKind === "image") { if (mediaKind === "image") {
return firstString(result?.images || undefined) || firstString(result?.originImages || undefined) || firstString(item.images) || null; return firstString(result?.images || undefined) || null;
} }
if (mediaKind === "video") return result?.video || item.video || null; if (mediaKind === "video") return result?.video || item.video || null;
return result?.voice || item.voice || item.audio || firstString(item.audios) || null; return result?.voice || item.voice || item.audio || firstString(item.audios) || null;
@ -180,7 +180,7 @@ function getHistoryPreviewUrl(item: HistoryItem, kind: HistoryTabKey): string |
const mediaKind = getHistoryKind(item, kind); const mediaKind = getHistoryKind(item, kind);
const result = firstResult(item); const result = firstResult(item);
if (mediaKind === "image") { if (mediaKind === "image") {
return firstString(result?.thumbs || undefined) || firstString(item.thumbs) || firstString(result?.images || undefined) || firstString(item.images) || null; return firstString(result?.thumbs || undefined) || null;
} }
if (mediaKind === "video") return result?.coverThumb || result?.cover || item.coverThumb || item.cover || result?.video || item.video || null; if (mediaKind === "video") return result?.coverThumb || result?.cover || item.coverThumb || item.cover || result?.video || item.video || null;
return null; return null;

247
src/components/composer/NodeHighDefinitionPanel.tsx

@ -1,35 +1,58 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Button, Select } from "antd"; import { GenerateActionIcon } from "@/components/media/MediaToolbarIcons";
import { ArrowUpOutlined, ThunderboltFilled } from "@ant-design/icons";
import { PopiModelSelect } from "@/components/nodes/PopiModelSelect"; import { PopiModelSelect } from "@/components/nodes/PopiModelSelect";
import { useModelStore } from "@/store/modelStore"; import { useModelStore } from "@/store/modelStore";
import type { PopiModelDetail } from "@/store/modelStore";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import type { SmartImageNodeData, WorkflowNode } from "@/types"; import type { SmartImageNodeData, WorkflowNode } from "@/types";
import { buildHighDefinitionParameters, chooseHighDefinitionModel, HIGH_DEFINITION_BATCH_SIZE, HIGH_DEFINITION_MODEL_MODE, HIGH_DEFINITION_SCALE, HIGH_DEFINITION_SUBTYPE } from "@/utils/highDefinitionNodes"; import type { ModelParameter } from "@/lib/providers/types";
import type { ExtensionFieldSchema, GenerationNodeConfig } from "@/utils/generationConfig";
import {
buildGenerationConfigDraftFromModelSchema,
buildSubmittableGenerationConfig,
normalizeExtensionFields,
updateModelParameterValue,
} from "@/utils/generationConfig";
import { buildHighDefinitionParameters, chooseHighDefinitionModel, HIGH_DEFINITION_BATCH_SIZE } from "@/utils/highDefinitionNodes";
import { toSelectedModel } from "@/utils/selectedModel"; import { toSelectedModel } from "@/utils/selectedModel";
interface NodeHighDefinitionPanelProps { interface NodeHighDefinitionPanelProps {
node: WorkflowNode; node: WorkflowNode;
} }
const SCALE_OPTIONS = [ type SchemaOption = { key: string; label: string; value: unknown };
{ label: "2", value: 2 },
{ label: "4", value: 4 },
{ label: "6", value: 6 },
];
const MODEL_MODE_OPTIONS = [ function schemaOptions(options: Array<{ label: string; value: unknown }> | undefined, fallbackValue: unknown): SchemaOption[] {
{ label: "通用", value: HIGH_DEFINITION_MODEL_MODE }, if (options && options.length > 0) {
]; return options.map((option, index) => ({
key: String(option.value ?? index),
label: option.label,
value: option.value,
}));
}
if (fallbackValue !== undefined && fallbackValue !== null && fallbackValue !== "") {
return [{ key: String(fallbackValue), label: String(fallbackValue), value: fallbackValue }];
}
return [];
}
function valueMatchesOption(optionValue: unknown, value: unknown): boolean {
return Object.is(optionValue, value) || String(optionValue).toLowerCase() === String(value ?? "").toLowerCase();
}
function selectedOptionKey(options: SchemaOption[], value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
return options.find((option) => valueMatchesOption(option.value, value))?.key;
}
function readScale(parameters: Record<string, unknown> | undefined): number { function parameterLabel(parameter: ModelParameter): string {
return typeof parameters?.scale === "number" && Number.isFinite(parameters.scale) ? parameters.scale : HIGH_DEFINITION_SCALE; return parameter.label ?? parameter.name;
} }
function readModelMode(parameters: Record<string, unknown> | undefined): string { function extensionFieldLabel(field: ExtensionFieldSchema): string {
return typeof parameters?.modelMode === "string" && parameters.modelMode ? parameters.modelMode : HIGH_DEFINITION_MODEL_MODE; return field.label ?? field.name;
} }
export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps) { export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps) {
@ -37,36 +60,128 @@ export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps)
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(node.id)); const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(node.id));
const highDefinitionModels = useModelStore((state) => state.byKind.highDefinition); const highDefinitionModels = useModelStore((state) => state.byKind.highDefinition);
const fetchModelDetail = useModelStore((state) => state.fetchModelDetail);
const data = node.data as SmartImageNodeData; const data = node.data as SmartImageNodeData;
const hydratedKeyRef = useRef<string | null>(null);
const selectedModel = useMemo( const selectedModel = useMemo(
() => data.selectedModel ?? chooseHighDefinitionModel(highDefinitionModels), () => data.selectedModel ?? chooseHighDefinitionModel(highDefinitionModels),
[data.selectedModel, highDefinitionModels] [data.selectedModel, highDefinitionModels]
); );
const activeModelDetail = useModelStore((state) => selectedModel.modelId ? state.detailsById[selectedModel.modelId] : undefined);
const parameters = buildHighDefinitionParameters(data.parameters); const parameters = buildHighDefinitionParameters(data.parameters);
const scale = readScale(parameters); const extraTaskParams = data.extraTaskParams ?? {};
const modelMode = readModelMode(parameters); const canRun = Boolean(selectedModel.modelId) || highDefinitionModels.length > 0;
const buildConfig = useCallback((
nextParameters = data.parameters ?? {},
nextExtraTaskParams = data.extraTaskParams ?? {},
model = selectedModel
): GenerationNodeConfig => ({
prompt: data.inputPrompt ?? "高清放大",
selectedModel: model,
inputImages: data.inputImages ?? [],
batchSize: HIGH_DEFINITION_BATCH_SIZE,
referenceSubjectList: data.referenceSubjectList ?? [],
parameters: buildHighDefinitionParameters(nextParameters),
extraTaskParams: nextExtraTaskParams,
}), [data.extraTaskParams, data.inputImages, data.inputPrompt, data.parameters, data.referenceSubjectList, selectedModel]);
const applyModelDetailConfig = useCallback((model = selectedModel, detail: PopiModelDetail | null | undefined = activeModelDetail) => {
const config = buildConfig(data.parameters ?? {}, data.extraTaskParams ?? {}, model);
const hydratedConfig = buildGenerationConfigDraftFromModelSchema(config, detail);
updateNodeData(node.id, {
selectedModel: model,
parameters: hydratedConfig.parameters,
extraTaskParams: hydratedConfig.extraTaskParams,
batchSize: HIGH_DEFINITION_BATCH_SIZE,
});
}, [activeModelDetail, buildConfig, data.extraTaskParams, data.parameters, node.id, selectedModel, updateNodeData]);
useEffect(() => {
if (!selectedModel.modelId) return;
const key = `${node.id}:${selectedModel.modelId}`;
if (hydratedKeyRef.current === key) return;
hydratedKeyRef.current = key;
void fetchModelDetail(selectedModel.modelId).then((detail) => {
applyModelDetailConfig(selectedModel, detail);
});
}, [applyModelDetailConfig, fetchModelDetail, node.id, selectedModel.modelId]);
const parameterRows = useMemo(() => {
return (activeModelDetail?.parameters ?? []).map((parameter) => {
const options = schemaOptions(parameter.options, parameter.default);
return {
parameter,
options,
selectedKey: selectedOptionKey(options, parameters[parameter.name]),
};
}).filter((row) => row.options.length > 0);
}, [activeModelDetail?.parameters, parameters]);
const updateParameters = useCallback((patch: Record<string, unknown>) => { const extensionFieldRows = useMemo(() => {
return normalizeExtensionFields(activeModelDetail?.extensionFields).map((field) => {
const options = schemaOptions(field.options, field.default);
return {
field,
options,
selectedKey: selectedOptionKey(options, extraTaskParams[field.name]),
};
}).filter((row) => row.options.length > 0);
}, [activeModelDetail?.extensionFields, extraTaskParams]);
const updateParameter = useCallback((name: string, value: unknown) => {
updateNodeData(node.id, { updateNodeData(node.id, {
parameters: buildHighDefinitionParameters({ parameters: buildHighDefinitionParameters(updateModelParameterValue(data.parameters, name, value)),
...(data.parameters ?? {}),
...patch,
}),
batchSize: HIGH_DEFINITION_BATCH_SIZE, batchSize: HIGH_DEFINITION_BATCH_SIZE,
subType: HIGH_DEFINITION_SUBTYPE,
}); });
}, [data.parameters, node.id, updateNodeData]); }, [data.parameters, node.id, updateNodeData]);
const handleRun = useCallback(() => { const updateExtensionField = useCallback((name: string, value: unknown) => {
updateNodeData(node.id, {
extraTaskParams: updateModelParameterValue(data.extraTaskParams, name, value),
batchSize: HIGH_DEFINITION_BATCH_SIZE,
});
}, [data.extraTaskParams, node.id, updateNodeData]);
const handleRun = useCallback(async () => {
const detail = selectedModel.modelId ? await fetchModelDetail(selectedModel.modelId) : null;
const submitConfig = buildSubmittableGenerationConfig(buildConfig(), detail ?? activeModelDetail);
updateNodeData(node.id, { updateNodeData(node.id, {
selectedModel, selectedModel: submitConfig.selectedModel,
parameters: buildHighDefinitionParameters(data.parameters), parameters: submitConfig.parameters,
extraTaskParams: submitConfig.extraTaskParams,
batchSize: HIGH_DEFINITION_BATCH_SIZE, batchSize: HIGH_DEFINITION_BATCH_SIZE,
subType: HIGH_DEFINITION_SUBTYPE,
error: null, error: null,
}); });
void regenerateNode(node.id); void regenerateNode(node.id);
}, [data.parameters, node.id, regenerateNode, selectedModel, updateNodeData]); }, [activeModelDetail, buildConfig, fetchModelDetail, node.id, regenerateNode, selectedModel.modelId, updateNodeData]);
const renderOptionButtons = (
selectedKey: string | undefined,
options: SchemaOption[],
onSelect: (value: unknown) => void
) => (
<div className="grid grid-cols-3 gap-1">
{options.map((option) => {
const selected = option.key === selectedKey;
return (
<button
key={option.key}
type="button"
aria-pressed={selected}
className={`h-8 rounded-lg border px-2 text-sm leading-5 transition-colors ${
selected
? "border-[var(--border-strong)] bg-[var(--surface-hover)] text-[var(--text-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-1)] text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
}`}
onClick={() => onSelect(option.value)}
>
{option.label}
</button>
);
})}
</div>
);
return ( return (
<section <section
@ -84,63 +199,49 @@ export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps)
<PopiModelSelect <PopiModelSelect
kind="highDefinition" kind="highDefinition"
value={selectedModel} value={selectedModel}
onChange={(_, providerModel) => { onChange={async (_, providerModel) => {
const nextModel = toSelectedModel(providerModel);
const detail = nextModel.modelId ? await fetchModelDetail(nextModel.modelId) : null;
const baseConfig = buildConfig({}, {}, nextModel);
const hydratedConfig = buildGenerationConfigDraftFromModelSchema(baseConfig, detail);
hydratedKeyRef.current = `${node.id}:${nextModel.modelId}`;
updateNodeData(node.id, { updateNodeData(node.id, {
selectedModel: toSelectedModel(providerModel), selectedModel: nextModel,
parameters: buildHighDefinitionParameters({}), parameters: hydratedConfig.parameters,
extraTaskParams: hydratedConfig.extraTaskParams,
batchSize: HIGH_DEFINITION_BATCH_SIZE, batchSize: HIGH_DEFINITION_BATCH_SIZE,
subType: HIGH_DEFINITION_SUBTYPE,
}); });
}} }}
size="compact" size="compact"
className="w-full" className="w-full"
/> />
<span className="text-[var(--text-primary)]"></span> {parameterRows.map((row) => (
<Select <div key={row.parameter.name} className="contents">
value={modelMode} <span className="text-[var(--text-primary)]">{parameterLabel(row.parameter)}</span>
options={MODEL_MODE_OPTIONS} {renderOptionButtons(row.selectedKey, row.options, (value) => updateParameter(row.parameter.name, value))}
size="middle" </div>
popupMatchSelectWidth={false}
suffixIcon={<span className="text-[10px] text-[var(--text-muted)]"></span>}
onChange={(value) => updateParameters({ modelMode: value })}
className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!border-[var(--border-subtle)] [&_.ant-select-selector]:!bg-[var(--surface-1)]"
/>
<span className="text-[var(--text-primary)]"></span>
<div className="grid grid-cols-3 gap-1">
{SCALE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={scale === option.value}
className={`h-8 rounded-lg border text-sm leading-5 transition-colors ${
scale === option.value
? "border-[var(--border-strong)] bg-[var(--surface-hover)] text-[var(--text-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-1)] text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
}`}
onClick={() => updateParameters({ scale: option.value })}
>
{option.label}
</button>
))} ))}
{extensionFieldRows.map((row) => (
<div key={row.field.name} className="contents">
<span className="text-[var(--text-primary)]">{extensionFieldLabel(row.field)}</span>
{renderOptionButtons(row.selectedKey, row.options, (value) => updateExtensionField(row.field.name, value))}
</div> </div>
))}
</div> </div>
<div className="mt-3 flex items-center justify-end gap-3"> <div className="mt-3 flex items-center justify-end">
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]"> <button
<ThunderboltFilled className="text-[10px]" /> type="button"
<span>15</span> aria-label={isRunning ? "生成中" : "生成"}
</div> title={isRunning ? "生成中" : "生成"}
<Button disabled={isRunning || !canRun}
type="primary"
shape="circle"
icon={<ArrowUpOutlined />}
loading={isRunning}
disabled={!selectedModel.modelId && highDefinitionModels.length === 0}
onClick={handleRun} onClick={handleRun}
className="!h-8 !w-8" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
/> >
<GenerateActionIcon />
</button>
</div> </div>
</section> </section>
); );

13
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -466,11 +466,7 @@ describe("executeNanoBanana", () => {
}, },
subType: 107, subType: 107,
parameters: { parameters: {
aiPlatform: "GATEWAY", resolution: "1k",
resolution: "4K",
modelMode: "general",
scale: 2,
batchSize: 1,
}, },
derivedImage: { sourceNodeId: "input-1", operation: "highDefinition" }, derivedImage: { sourceNodeId: "input-1", operation: "highDefinition" },
}); });
@ -520,12 +516,9 @@ describe("executeNanoBanana", () => {
subType: 107, subType: 107,
}, },
parameters: { parameters: {
aiPlatform: "GATEWAY", resolution: "1k",
resolution: "4K",
modelMode: "general",
scale: 2,
batchSize: 1,
}, },
batchSize: 1,
}); });
expect(body.parameters.ratio).toBeUndefined(); expect(body.parameters.ratio).toBeUndefined();

33
src/utils/__tests__/highDefinitionNodes.test.ts

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createHighDefinitionImageNode, readHighDefinitionSourceImageDimensions } from "../highDefinitionNodes"; import { chooseHighDefinitionModel, createHighDefinitionImageNode, readHighDefinitionSourceImageDimensions } from "../highDefinitionNodes";
vi.mock("@/utils/nodeDimensions", () => ({ vi.mock("@/utils/nodeDimensions", () => ({
getImageDimensions: vi.fn().mockResolvedValue({ width: 800, height: 1200 }), getImageDimensions: vi.fn().mockResolvedValue({ width: 800, height: 1200 }),
@ -32,13 +32,9 @@ describe("highDefinitionNodes", () => {
{ x: 320, y: 40 }, { x: 320, y: 40 },
expect.objectContaining({ expect.objectContaining({
dimensions: { width: 1536, height: 2752 }, dimensions: { width: 1536, height: 2752 },
subType: 107,
parameters: expect.objectContaining({
aiPlatform: "GATEWAY",
resolution: "4K",
modelMode: "general",
scale: 2,
batchSize: 1, batchSize: 1,
parameters: expect.objectContaining({
resolution: "1k",
}), }),
derivedImage: { derivedImage: {
sourceNodeId: "source-1", sourceNodeId: "source-1",
@ -55,6 +51,29 @@ describe("highDefinitionNodes", () => {
expect(selectSingleNode).toHaveBeenCalledWith("hd-1"); expect(selectSingleNode).toHaveBeenCalledWith("hd-1");
}); });
it("selects the first high-definition model by default", () => {
expect(chooseHighDefinitionModel([
{
id: "first-model",
name: "First model",
description: null,
provider: "popiserver",
capabilities: ["image-to-image"],
},
{
id: "default-model",
name: "Default model",
description: null,
provider: "popiserver",
capabilities: ["image-to-image"],
metadata: { isDefault: 1, aiModelId: 74 },
},
])).toMatchObject({
modelId: "first-model",
displayName: "First model",
});
});
it("uses existing source dimensions before loading the image", async () => { it("uses existing source dimensions before loading the image", async () => {
await expect( await expect(
readHighDefinitionSourceImageDimensions( readHighDefinitionSourceImageDimensions(

13
src/utils/highDefinitionNodes.ts

@ -6,9 +6,7 @@ import { toSelectedModel } from "@/utils/selectedModel";
import { getImageDimensions } from "@/utils/nodeDimensions"; import { getImageDimensions } from "@/utils/nodeDimensions";
export const HIGH_DEFINITION_SUBTYPE = 107; export const HIGH_DEFINITION_SUBTYPE = 107;
export const HIGH_DEFINITION_RESOLUTION = "4K"; export const HIGH_DEFINITION_RESOLUTION = "1k";
export const HIGH_DEFINITION_MODEL_MODE = "general";
export const HIGH_DEFINITION_SCALE = 2;
export const HIGH_DEFINITION_BATCH_SIZE = 1; export const HIGH_DEFINITION_BATCH_SIZE = 1;
export const HIGH_DEFINITION_MODEL_CODE = "jimeng_i2i_seed3_tilesr_cvtob"; export const HIGH_DEFINITION_MODEL_CODE = "jimeng_i2i_seed3_tilesr_cvtob";
@ -34,9 +32,7 @@ export function isHighDefinitionDerivedImageNode(data: unknown): boolean {
} }
export function chooseHighDefinitionModel(models: ProviderModel[]): SelectedModel { export function chooseHighDefinitionModel(models: ProviderModel[]): SelectedModel {
const model = models.find((item) => item.metadata?.isDefault === 1) const model = models[0];
?? models.find((item) => item.metadata?.aiModelId === 74 || item.metadata?.aiModelCode === HIGH_DEFINITION_MODEL_CODE || item.metadata?.aiModelCodeAlias === HIGH_DEFINITION_MODEL_CODE)
?? models[0];
return model ? toSelectedModel(model) : HIGH_DEFINITION_FALLBACK_MODEL; return model ? toSelectedModel(model) : HIGH_DEFINITION_FALLBACK_MODEL;
} }
@ -45,11 +41,7 @@ export function buildHighDefinitionParameters(
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
...parameters, ...parameters,
aiPlatform: "GATEWAY",
resolution: typeof parameters.resolution === "string" && parameters.resolution ? parameters.resolution : HIGH_DEFINITION_RESOLUTION, resolution: typeof parameters.resolution === "string" && parameters.resolution ? parameters.resolution : HIGH_DEFINITION_RESOLUTION,
modelMode: typeof parameters.modelMode === "string" && parameters.modelMode ? parameters.modelMode : HIGH_DEFINITION_MODEL_MODE,
scale: typeof parameters.scale === "number" && Number.isFinite(parameters.scale) ? parameters.scale : HIGH_DEFINITION_SCALE,
batchSize: HIGH_DEFINITION_BATCH_SIZE,
}; };
} }
@ -112,7 +104,6 @@ export function createHighDefinitionImageNode({
referenceSubjectList: [], referenceSubjectList: [],
parameters: buildHighDefinitionParameters(), parameters: buildHighDefinitionParameters(),
extraTaskParams: {}, extraTaskParams: {},
subType: HIGH_DEFINITION_SUBTYPE,
customTitle: "高清", customTitle: "高清",
derivedImage: { derivedImage: {
sourceNodeId, sourceNodeId,

Loading…
Cancel
Save