feat: adapt image tuning by engine

This commit is contained in:
inman
2026-05-29 14:32:02 +08:00
parent 4b21d2999c
commit e36f28a668
12 changed files with 138 additions and 10 deletions

View File

@@ -31,6 +31,7 @@ export async function POST(request: Request) {
min_ratio?: number; min_ratio?: number;
max_ratio?: number; max_ratio?: number;
force_single?: boolean; force_single?: boolean;
quality?: string;
}>(request); }>(request);
const capability = body.capability || "image.generate"; const capability = body.capability || "image.generate";
const assembled = body.promptAssembly const assembled = body.promptAssembly
@@ -49,7 +50,8 @@ export async function POST(request: Request) {
height: asNumber(body.height), height: asNumber(body.height),
min_ratio: asNumber(body.min_ratio), min_ratio: asNumber(body.min_ratio),
max_ratio: asNumber(body.max_ratio), max_ratio: asNumber(body.max_ratio),
force_single: Boolean(body.force_single) force_single: Boolean(body.force_single),
quality: typeof body.quality === "string" ? body.quality : undefined
}, requestOrigin(request)); }, requestOrigin(request));
return jsonOk({ job: await getGenerationJob(job.id) }, { status: 202 }); return jsonOk({ job: await getGenerationJob(job.id) }, { status: 202 });
} catch (error) { } catch (error) {

View File

@@ -120,8 +120,9 @@ export async function GET(request: Request) {
}, },
width: { type: "integer", example: 1440 }, width: { type: "integer", example: 1440 },
height: { type: "integer", example: 2560 }, height: { type: "integer", example: 2560 },
scale: { type: "number", minimum: 1, maximum: 100 }, scale: { type: "number", minimum: 1, maximum: 100, description: "Jimeng text influence for image.generate and detail strength for image.upscale." },
force_single: { type: "boolean" }, force_single: { type: "boolean" },
quality: { type: "string", enum: ["low", "medium", "high"], description: "EvoLink image quality for image.generate and image.inpaint." },
resolution: { type: "string", enum: ["4k", "8k"], description: "Upscale resolution for image.upscale." }, resolution: { type: "string", enum: ["4k", "8k"], description: "Upscale resolution for image.upscale." },
seed: { type: "integer" }, seed: { type: "integer" },
priority: { type: "integer", minimum: -100, maximum: 100 }, priority: { type: "integer", minimum: -100, maximum: 100 },

View File

@@ -13,12 +13,31 @@ import type { PromptMaterial } from "@/lib/prompt/assembler";
type GenerateMode = "image" | "video"; type GenerateMode = "image" | "video";
type StudioMode = GenerateMode | ImageEditMode; type StudioMode = GenerateMode | ImageEditMode;
type MaterialKind = PromptMaterial["type"]; type MaterialKind = PromptMaterial["type"];
type ImageGenerateEngine = "jimeng" | "evolink";
type MentionState = { type MentionState = {
start: number; start: number;
query: string; query: string;
}; };
type HealthCapability = {
id: string;
engine?: string;
};
const jimengInfluenceOptions = [
{ id: "creative", label: "创意 35", scale: 35 },
{ id: "balanced", label: "均衡 50", scale: 50 },
{ id: "precise", label: "贴合 70", scale: 70 },
{ id: "strict", label: "严格 85", scale: 85 }
];
const evolinkQualityOptions = [
{ id: "low", label: "快速", quality: "low" },
{ id: "medium", label: "标准", quality: "medium" },
{ id: "high", label: "精细", quality: "high" }
];
const imageSizePresets = [ const imageSizePresets = [
{ label: "1:1", width: 2048, height: 2048 }, { label: "1:1", width: 2048, height: 2048 },
{ label: "4:3", width: 2304, height: 1728 }, { label: "4:3", width: 2304, height: 1728 },
@@ -41,7 +60,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [imageSize, setImageSize] = useState(imageSizePresets[0]); const [imageSize, setImageSize] = useState(imageSizePresets[0]);
const [imageScale, setImageScale] = useState(50); const [imageEngine, setImageEngine] = useState<ImageGenerateEngine>("jimeng");
const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id);
const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id);
const [forceSingle, setForceSingle] = useState(true); const [forceSingle, setForceSingle] = useState(true);
const [videoRatio, setVideoRatio] = useState("9:16"); const [videoRatio, setVideoRatio] = useState("9:16");
const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT);
@@ -59,6 +80,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
const isImageEditMode = mode === "inpaint" || mode === "upscale"; const isImageEditMode = mode === "inpaint" || mode === "upscale";
const generateMode: GenerateMode = mode === "video" ? "video" : "image"; const generateMode: GenerateMode = mode === "video" ? "video" : "image";
const prompt = promptByMode[generateMode]; const prompt = promptByMode[generateMode];
const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1];
const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1];
const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE); const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE);
const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE; const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE;
const mentionSuggestions = useMemo(() => { const mentionSuggestions = useMemo(() => {
@@ -90,6 +113,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card"); if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card");
}, [materials.length, materialPage]); }, [materials.length, materialPage]);
useEffect(() => {
let active = true;
void fetch("/api/health")
.then((response) => response.ok ? response.json() : null)
.then((payload: { capabilities?: HealthCapability[] } | null) => {
const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine;
if (active && (engine === "evolink" || engine === "jimeng")) setImageEngine(engine);
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
function setPrompt(value: string) { function setPrompt(value: string) {
setPromptByMode((items) => ({ ...items, [generateMode]: value })); setPromptByMode((items) => ({ ...items, [generateMode]: value }));
} }
@@ -229,7 +266,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
materials: materials.filter((material) => material.type === "image"), materials: materials.filter((material) => material.type === "image"),
width: imageSize.width, width: imageSize.width,
height: imageSize.height, height: imageSize.height,
scale: imageScale, ...(imageEngine === "evolink"
? { quality: selectedEvolinkQuality.quality }
: { scale: selectedJimengInfluence.scale }),
force_single: forceSingle force_single: forceSingle
} }
: { : {
@@ -432,9 +471,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
{imageSizePresets.map((preset) => <option key={preset.label}>{preset.label}</option>)} {imageSizePresets.map((preset) => <option key={preset.label}>{preset.label}</option>)}
</select> </select>
</div> </div>
<div className="field inline-field range-field"> <div className="field inline-field">
<label htmlFor="imageScale"> {imageScale}</label> <label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : "文本影响"}</label>
<input id="imageScale" type="range" min="1" max="100" value={imageScale} onChange={(event) => setImageScale(Number(event.target.value))} /> {imageEngine === "evolink" ? (
<select id="imageEngineTuning" value={evolinkQuality} onChange={(event) => setEvolinkQuality(event.target.value)}>
{evolinkQualityOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
) : (
<select id="imageEngineTuning" value={jimengInfluence} onChange={(event) => setJimengInfluence(event.target.value)}>
{jimengInfluenceOptions.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
)}
</div> </div>
<label className="toggle-line"> <label className="toggle-line">
<input type="checkbox" checked={forceSingle} onChange={(event) => setForceSingle(event.target.checked)} /> <input type="checkbox" checked={forceSingle} onChange={(event) => setForceSingle(event.target.checked)} />

View File

@@ -174,10 +174,14 @@ curl -X POST https://你的域名/api/v1/assets \
"imageUrls": ["https://example.com/reference.png"], "imageUrls": ["https://example.com/reference.png"],
"width": 1440, "width": 1440,
"height": 2560, "height": 2560,
"scale": 50,
"quality": "medium",
"force_single": true "force_single": true
} }
``` ```
图片生成参数会按当前引擎生效:即梦使用 `scale` 控制文本影响EvoLink 使用 `quality` 控制生成质量。
智能超清: 智能超清:
```json ```json

View File

@@ -136,3 +136,8 @@
- Uploaded and generated assets created through public API flows are tagged as `api-client:<clientId>` so integrations can query and download their own results later. - Uploaded and generated assets created through public API flows are tagged as `api-client:<clientId>` so integrations can query and download their own results later.
- OpenAPI is generated dynamically from the current deployment origin at `/api/v1/openapi.json`. - OpenAPI is generated dynamically from the current deployment origin at `/api/v1/openapi.json`.
- Operations handoff docs live in `docs/DEPLOYMENT.md`; partner API docs live in `docs/API.md`. - Operations handoff docs live in `docs/DEPLOYMENT.md`; partner API docs live in `docs/API.md`.
## 2026-05-29 Image Tuning Findings
- Jimeng image generation supports the current `scale` parameter, so UI presets should submit numeric text-influence values for that engine.
- EvoLink image generation does not use Jimeng `scale`; the per-request engine-aware control should submit EvoLink `quality` instead.
- Current local `/api/health` reports `image.generate` using EvoLink, so `/create` should show `生成质量` options rather than `文本影响`.

View File

@@ -87,7 +87,8 @@ export function buildEvolinkImagePayload(
}; };
if (!payload.prompt) throw new Error("Prompt is required for image generation."); if (!payload.prompt) throw new Error("Prompt is required for image generation.");
if (settings.quality) payload.quality = settings.quality; const quality = cleanOptional(typeof input.quality === "string" ? input.quality : undefined) || settings.quality;
if (quality) payload.quality = quality;
if (settings.resolution) payload.resolution = settings.resolution; if (settings.resolution) payload.resolution = settings.resolution;
assignSize(payload, input); assignSize(payload, input);

View File

@@ -42,6 +42,7 @@ export type SubmitImageJobInput = {
max_ratio?: number; max_ratio?: number;
force_single?: boolean; force_single?: boolean;
resolution?: "4k" | "8k"; resolution?: "4k" | "8k";
quality?: string;
seed?: number; seed?: number;
retryOf?: string; retryOf?: string;
idempotencyKey?: string; idempotencyKey?: string;

View File

@@ -35,6 +35,7 @@ export type PublicJobCreateBody = {
max_ratio?: number; max_ratio?: number;
force_single?: boolean; force_single?: boolean;
resolution?: "4k" | "8k"; resolution?: "4k" | "8k";
quality?: string;
seed?: number; seed?: number;
}; };
@@ -101,6 +102,7 @@ export async function createPublicGenerationJob(input: {
max_ratio: asNumber(input.body.max_ratio), max_ratio: asNumber(input.body.max_ratio),
force_single: Boolean(input.body.force_single), force_single: Boolean(input.body.force_single),
resolution: input.body.resolution, resolution: input.body.resolution,
quality: normalizeQuality(input.body.quality),
seed: asNumber(input.body.seed) seed: asNumber(input.body.seed)
} satisfies SubmitImageJobInput, input.origin); } satisfies SubmitImageJobInput, input.origin);
return { job, reused: false }; return { job, reused: false };
@@ -132,6 +134,12 @@ function asNumber(value: unknown): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }
function normalizeQuality(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return ["low", "medium", "high"].includes(normalized) ? normalized : undefined;
}
function fingerprintBody(body: PublicJobCreateBody): string { function fingerprintBody(body: PublicJobCreateBody): string {
const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body; const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body;
return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex"); return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex");

View File

@@ -295,6 +295,27 @@
- Local HTTP smoke test uploaded a PNG through `/api/v1/assets`, fetched `/api/v1/assets/:id`, and downloaded matching binary bytes from `/api/v1/assets/:id/download`. - Local HTTP smoke test uploaded a PNG through `/api/v1/assets`, fetched `/api/v1/assets/:id`, and downloaded matching binary bytes from `/api/v1/assets/:id/download`.
- Mock-provider task flow created a queued job through `/api/v1/jobs`, Worker processed it to `succeeded`, generated output asset carried the API client tag, and download returned an attachment response. - Mock-provider task flow created a queued job through `/api/v1/jobs`, Worker processed it to `succeeded`, generated output asset carried the API client tag, and download returned an attachment response.
## Session: 2026-05-29 - Engine-Aware Image Tuning
### Implementation
- **Status:** complete
- Actions taken:
- Replaced the image generation text-influence range slider with select options.
- Added create-page engine detection from `/api/health`.
- For Jimeng image generation, the UI now shows `文本影响` options: `创意 35`, `均衡 50`, `贴合 70`, `严格 85`, and submits `scale`.
- For EvoLink image generation, the UI now shows `生成质量` options: `快速`, `标准`, `精细`, and submits `quality`.
- Added per-request EvoLink `quality` support in the payload builder.
- Updated public API request typing, OpenAPI schema, API docs, and focused tests.
### Verification
- **Status:** complete
- Results:
- `npm test`: 7 files / 22 tests passed.
- `npm run build`: production build succeeded.
- Agent-browser snapshot on `/create` showed current EvoLink mode rendering `生成质量` with `快速 / 标准 / 精细`.
- Desktop and mobile screenshots showed the create-page controls fitting cleanly.
- Browser page errors list was empty.
## Error Log - Server Deployment Support ## Error Log - Server Deployment Support
| Timestamp | Error | Attempt | Resolution | | Timestamp | Error | Attempt | Resolution |
|-----------|-------|---------|------------| |-----------|-------|---------|------------|

View File

@@ -86,6 +86,14 @@ Complete - latest update: Task management and public API v1
- [x] Verify real HTTP API calls, Worker task processing, tests, and production build - [x] Verify real HTTP API calls, Worker task processing, tests, and production build
- **Status:** complete - **Status:** complete
### Phase 12: Engine-Aware Image Tuning
- [x] Replace the free text-influence slider with user-facing option presets
- [x] Detect the active image generation engine from `/api/health`
- [x] Send Jimeng `scale` only when the active engine is Jimeng
- [x] Send EvoLink `quality` only when the active engine is EvoLink
- [x] Verify tests, production build, and desktop/mobile create-page layout
- **Status:** complete
## Key Questions ## Key Questions
1. How should the selected image engine be stored and exposed in settings? 1. How should the selected image engine be stored and exposed in settings?
2. Which current capabilities should EvoLink handle first? 2. Which current capabilities should EvoLink handle first?

View File

@@ -34,15 +34,18 @@ describe("EvoLink image client helpers", () => {
it("maps inpainting original and mask URLs", () => { it("maps inpainting original and mask URLs", () => {
const payload = buildEvolinkImagePayload("image.inpaint", { const payload = buildEvolinkImagePayload("image.inpaint", {
prompt: "移除背景杂物", prompt: "移除背景杂物",
quality: "high",
imageUrls: ["https://example.com/original.png", "https://example.com/mask.png"] imageUrls: ["https://example.com/original.png", "https://example.com/mask.png"]
}, { }, {
baseUrl: "https://api.evolink.ai", baseUrl: "https://api.evolink.ai",
model: "gpt-image-2" model: "gpt-image-2",
quality: "medium"
}); });
expect(payload).toMatchObject({ expect(payload).toMatchObject({
image_urls: ["https://example.com/original.png"], image_urls: ["https://example.com/original.png"],
mask_url: "https://example.com/mask.png" mask_url: "https://example.com/mask.png",
quality: "high"
}); });
}); });

View File

@@ -21,6 +21,9 @@ const envNames = [
"SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_SERVICE_ROLE_KEY",
"ZHINIAN_API_KEYS", "ZHINIAN_API_KEYS",
"JIMENG_VISUAL_MOCK", "JIMENG_VISUAL_MOCK",
"IMAGE_GENERATE_ENGINE",
"EVOLINK_MOCK",
"EVOLINK_API_KEY",
"VOLCENGINE_ACCESS_KEY_ID", "VOLCENGINE_ACCESS_KEY_ID",
"VOLCENGINE_SECRET_ACCESS_KEY", "VOLCENGINE_SECRET_ACCESS_KEY",
"ZHINIAN_WEBHOOK_SECRET" "ZHINIAN_WEBHOOK_SECRET"
@@ -33,6 +36,9 @@ describe("task management and public API helpers", () => {
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b"; process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b";
process.env.JIMENG_VISUAL_MOCK = "true"; process.env.JIMENG_VISUAL_MOCK = "true";
delete process.env.IMAGE_GENERATE_ENGINE;
delete process.env.EVOLINK_MOCK;
delete process.env.EVOLINK_API_KEY;
delete process.env.NEXT_PUBLIC_SUPABASE_URL; delete process.env.NEXT_PUBLIC_SUPABASE_URL;
delete process.env.SUPABASE_SERVICE_ROLE_KEY; delete process.env.SUPABASE_SERVICE_ROLE_KEY;
delete process.env.VOLCENGINE_ACCESS_KEY_ID; delete process.env.VOLCENGINE_ACCESS_KEY_ID;
@@ -91,6 +97,27 @@ describe("task management and public API helpers", () => {
})).rejects.toBeInstanceOf(PublicApiConflictError); })).rejects.toBeInstanceOf(PublicApiConflictError);
}); });
it("passes EvoLink quality through public image jobs", async () => {
process.env.IMAGE_GENERATE_ENGINE = "evolink";
process.env.EVOLINK_MOCK = "true";
process.env.EVOLINK_API_KEY = "test-key";
const result = await createPublicGenerationJob({
client: { id: "agent-a", key: "secret-a" },
request: new Request("http://local.test/api/v1/jobs"),
origin: "http://local.test",
body: {
capability: "image.generate",
prompt: "商品海报",
quality: "high"
}
});
expect(result.job.provider).toBe("mock");
expect(result.job.requestPayload.providerPayload).toMatchObject({
model: "gpt-image-2",
quality: "high"
});
});
it("claims local jobs without duplicate ownership", async () => { it("claims local jobs without duplicate ownership", async () => {
await Promise.all(Array.from({ length: 6 }, (_, index) => createGenerationJob({ await Promise.all(Array.from({ length: 6 }, (_, index) => createGenerationJob({
ownerId: DEFAULT_OWNER_ID, ownerId: DEFAULT_OWNER_ID,