feat: adapt image tuning by engine
This commit is contained in:
@@ -31,6 +31,7 @@ export async function POST(request: Request) {
|
||||
min_ratio?: number;
|
||||
max_ratio?: number;
|
||||
force_single?: boolean;
|
||||
quality?: string;
|
||||
}>(request);
|
||||
const capability = body.capability || "image.generate";
|
||||
const assembled = body.promptAssembly
|
||||
@@ -49,7 +50,8 @@ export async function POST(request: Request) {
|
||||
height: asNumber(body.height),
|
||||
min_ratio: asNumber(body.min_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));
|
||||
return jsonOk({ job: await getGenerationJob(job.id) }, { status: 202 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -120,8 +120,9 @@ export async function GET(request: Request) {
|
||||
},
|
||||
width: { type: "integer", example: 1440 },
|
||||
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" },
|
||||
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." },
|
||||
seed: { type: "integer" },
|
||||
priority: { type: "integer", minimum: -100, maximum: 100 },
|
||||
|
||||
@@ -13,12 +13,31 @@ import type { PromptMaterial } from "@/lib/prompt/assembler";
|
||||
type GenerateMode = "image" | "video";
|
||||
type StudioMode = GenerateMode | ImageEditMode;
|
||||
type MaterialKind = PromptMaterial["type"];
|
||||
type ImageGenerateEngine = "jimeng" | "evolink";
|
||||
|
||||
type MentionState = {
|
||||
start: number;
|
||||
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 = [
|
||||
{ label: "1:1", width: 2048, height: 2048 },
|
||||
{ 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 [notice, setNotice] = useState<string | null>(null);
|
||||
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 [videoRatio, setVideoRatio] = useState("9:16");
|
||||
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 generateMode: GenerateMode = mode === "video" ? "video" : "image";
|
||||
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 materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE;
|
||||
const mentionSuggestions = useMemo(() => {
|
||||
@@ -90,6 +113,20 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
|
||||
if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card");
|
||||
}, [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) {
|
||||
setPromptByMode((items) => ({ ...items, [generateMode]: value }));
|
||||
}
|
||||
@@ -229,7 +266,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
|
||||
materials: materials.filter((material) => material.type === "image"),
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
scale: imageScale,
|
||||
...(imageEngine === "evolink"
|
||||
? { quality: selectedEvolinkQuality.quality }
|
||||
: { scale: selectedJimengInfluence.scale }),
|
||||
force_single: forceSingle
|
||||
}
|
||||
: {
|
||||
@@ -432,9 +471,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
|
||||
{imageSizePresets.map((preset) => <option key={preset.label}>{preset.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field inline-field range-field">
|
||||
<label htmlFor="imageScale">文本影响 {imageScale}</label>
|
||||
<input id="imageScale" type="range" min="1" max="100" value={imageScale} onChange={(event) => setImageScale(Number(event.target.value))} />
|
||||
<div className="field inline-field">
|
||||
<label htmlFor="imageEngineTuning">{imageEngine === "evolink" ? "生成质量" : "文本影响"}</label>
|
||||
{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>
|
||||
<label className="toggle-line">
|
||||
<input type="checkbox" checked={forceSingle} onChange={(event) => setForceSingle(event.target.checked)} />
|
||||
|
||||
@@ -174,10 +174,14 @@ curl -X POST https://你的域名/api/v1/assets \
|
||||
"imageUrls": ["https://example.com/reference.png"],
|
||||
"width": 1440,
|
||||
"height": 2560,
|
||||
"scale": 50,
|
||||
"quality": "medium",
|
||||
"force_single": true
|
||||
}
|
||||
```
|
||||
|
||||
图片生成参数会按当前引擎生效:即梦使用 `scale` 控制文本影响,EvoLink 使用 `quality` 控制生成质量。
|
||||
|
||||
智能超清:
|
||||
|
||||
```json
|
||||
|
||||
@@ -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.
|
||||
- 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`.
|
||||
|
||||
## 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 `文本影响`.
|
||||
|
||||
@@ -87,7 +87,8 @@ export function buildEvolinkImagePayload(
|
||||
};
|
||||
|
||||
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;
|
||||
assignSize(payload, input);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export type SubmitImageJobInput = {
|
||||
max_ratio?: number;
|
||||
force_single?: boolean;
|
||||
resolution?: "4k" | "8k";
|
||||
quality?: string;
|
||||
seed?: number;
|
||||
retryOf?: string;
|
||||
idempotencyKey?: string;
|
||||
|
||||
@@ -35,6 +35,7 @@ export type PublicJobCreateBody = {
|
||||
max_ratio?: number;
|
||||
force_single?: boolean;
|
||||
resolution?: "4k" | "8k";
|
||||
quality?: string;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
@@ -101,6 +102,7 @@ export async function createPublicGenerationJob(input: {
|
||||
max_ratio: asNumber(input.body.max_ratio),
|
||||
force_single: Boolean(input.body.force_single),
|
||||
resolution: input.body.resolution,
|
||||
quality: normalizeQuality(input.body.quality),
|
||||
seed: asNumber(input.body.seed)
|
||||
} satisfies SubmitImageJobInput, input.origin);
|
||||
return { job, reused: false };
|
||||
@@ -132,6 +134,12 @@ function asNumber(value: unknown): number | 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 {
|
||||
const { idempotencyKey: _idempotencyKey, ...fingerprintSource } = body;
|
||||
return createHash("sha256").update(stableStringify(fingerprintSource)).digest("hex");
|
||||
|
||||
21
progress.md
21
progress.md
@@ -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`.
|
||||
- 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
|
||||
| Timestamp | Error | Attempt | Resolution |
|
||||
|-----------|-------|---------|------------|
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
1. How should the selected image engine be stored and exposed in settings?
|
||||
2. Which current capabilities should EvoLink handle first?
|
||||
|
||||
@@ -34,15 +34,18 @@ describe("EvoLink image client helpers", () => {
|
||||
it("maps inpainting original and mask URLs", () => {
|
||||
const payload = buildEvolinkImagePayload("image.inpaint", {
|
||||
prompt: "移除背景杂物",
|
||||
quality: "high",
|
||||
imageUrls: ["https://example.com/original.png", "https://example.com/mask.png"]
|
||||
}, {
|
||||
baseUrl: "https://api.evolink.ai",
|
||||
model: "gpt-image-2"
|
||||
model: "gpt-image-2",
|
||||
quality: "medium"
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
image_urls: ["https://example.com/original.png"],
|
||||
mask_url: "https://example.com/mask.png"
|
||||
mask_url: "https://example.com/mask.png",
|
||||
quality: "high"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ const envNames = [
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
"ZHINIAN_API_KEYS",
|
||||
"JIMENG_VISUAL_MOCK",
|
||||
"IMAGE_GENERATE_ENGINE",
|
||||
"EVOLINK_MOCK",
|
||||
"EVOLINK_API_KEY",
|
||||
"VOLCENGINE_ACCESS_KEY_ID",
|
||||
"VOLCENGINE_SECRET_ACCESS_KEY",
|
||||
"ZHINIAN_WEBHOOK_SECRET"
|
||||
@@ -33,6 +36,9 @@ describe("task management and public API helpers", () => {
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b";
|
||||
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.SUPABASE_SERVICE_ROLE_KEY;
|
||||
delete process.env.VOLCENGINE_ACCESS_KEY_ID;
|
||||
@@ -91,6 +97,27 @@ describe("task management and public API helpers", () => {
|
||||
})).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 () => {
|
||||
await Promise.all(Array.from({ length: 6 }, (_, index) => createGenerationJob({
|
||||
ownerId: DEFAULT_OWNER_ID,
|
||||
|
||||
Reference in New Issue
Block a user