新增图片生成与上传执行管线:nano-banana-2真实出图→上传→公网URL回填→上传后二次生成API调用稿
- 新增 image_generation_upload_rules.md、post_upload_call_draft_rules.md 规则文件,scripts/ 脚本目录 - 工作流新增阶段 5.1 图片生成与上传、5.2 上传后调用稿分支,调整原有步骤编号 - 所有模板和规则强制要求:API 图片输入使用上传后的公网 URL / asset:// URI,禁止本地路径或占位符 - 官网 Prompt 模板强制要求使用 @官网上传名,绑定已上传资产 - 附件/API 资产清单新增状态追踪列(pending_generation / pending_upload / generated_uploaded_ready 等)和 uploaded_assets.json 回填规则 - api_payload_rules.md 新增"上传后二次生成硬约束",要求 JSONL 必须在图片上传完成后基于真实 URL 重新生成 - image_model_provider_rules.md 明确 nano-banana-2 为默认执行资源(非仅提示词名称),新增图片生成/上传执行标记 --- 核心变更概括:工作流从"只写提示词"升级为"提示词→真实出图→上传→获取公网 URL→回填→二次生成调用稿"的完整资产管线。
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_UPLOAD_URL =
|
||||
"https://lms.laitool.cn/lms/FileUpload/FileUpload/01564ca6c907eab817058e5356bb40b57cd3f3171adbbc9ea49be0de448cc68e";
|
||||
|
||||
function argValue(name, fallback = undefined) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1 || index + 1 >= process.argv.length) return fallback;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
function requireArg(name) {
|
||||
const value = argValue(name);
|
||||
if (!value) {
|
||||
throw new Error(`Missing required argument: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function sanitizeName(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "_")
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
function extensionFromMime(contentType) {
|
||||
if (contentType === "image/png") return ".png";
|
||||
if (contentType === "image/webp") return ".webp";
|
||||
return ".jpg";
|
||||
}
|
||||
|
||||
function extractInlineImage(response) {
|
||||
const candidates = response?.candidates ?? [];
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
const inlineData = part.inlineData ?? part.inline_data;
|
||||
if (inlineData?.data) {
|
||||
return {
|
||||
base64: inlineData.data,
|
||||
contentType: inlineData.mimeType ?? inlineData.mime_type ?? "image/jpeg",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Image model response did not contain inline image data.");
|
||||
}
|
||||
|
||||
function extractUploadUrl(resultText) {
|
||||
try {
|
||||
const json = JSON.parse(resultText);
|
||||
return (
|
||||
json.url ??
|
||||
json.fileUrl ??
|
||||
json.fileURL ??
|
||||
json.data?.url ??
|
||||
json.data?.fileUrl ??
|
||||
json.data?.fileURL ??
|
||||
json.data?.path ??
|
||||
json.result?.url ??
|
||||
json.result?.fileUrl ??
|
||||
""
|
||||
);
|
||||
} catch {
|
||||
const match = resultText.match(/https?:\/\/[^\s"'<>]+/);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
async function generateImage({ prompt, model, apiKey, aspectRatio, contentType }) {
|
||||
const endpoint = `https://magic666.top/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["IMAGE"],
|
||||
...(aspectRatio ? { aspectRatio } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image generation failed: ${response.status} ${responseText}`);
|
||||
}
|
||||
|
||||
const image = extractInlineImage(JSON.parse(responseText));
|
||||
return {
|
||||
base64: image.base64,
|
||||
contentType: contentType ?? image.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImage({ base64, fileName, contentType, uploadUrl }) {
|
||||
const originalSize = Buffer.byteLength(Buffer.from(base64, "base64")).toString();
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
redirect: "follow",
|
||||
body: JSON.stringify({
|
||||
file: base64,
|
||||
fileName,
|
||||
contentType,
|
||||
metadata: {
|
||||
uploadTime: new Date().toISOString(),
|
||||
originalSize,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const resultText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image upload failed: ${response.status} ${resultText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
raw: resultText,
|
||||
url: extractUploadUrl(resultText),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jobsPath = requireArg("--jobs");
|
||||
const outDir = path.resolve(argValue("--out-dir", "outputs/generated_assets"));
|
||||
const manifestPath = path.resolve(argValue("--manifest", path.join(outDir, "uploaded_assets.json")));
|
||||
// const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
|
||||
|
||||
const apiKey = "sk-QLEqdjgAE457aFsxmvUHdoj0EnyBcFaX23I4DxCJ8bgnrJNw"
|
||||
const model = argValue("--model", process.env.NANO_BANANA_MODEL ?? "nano-banana-2");
|
||||
const uploadUrl = argValue("--upload-url", process.env.IMAGE_UPLOAD_URL ?? DEFAULT_UPLOAD_URL);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("Set GEMINI_API_KEY or GOOGLE_API_KEY before running image generation.");
|
||||
}
|
||||
const jobs = await readJson(jobsPath);
|
||||
if (!Array.isArray(jobs)) {
|
||||
throw new Error("Jobs file must be a JSON array.");
|
||||
}
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const results = [];
|
||||
|
||||
for (const [index, job] of jobs.entries()) {
|
||||
if (!job.prompt) {
|
||||
throw new Error(`Job ${index + 1} is missing prompt.`);
|
||||
}
|
||||
|
||||
const generated = await generateImage({
|
||||
prompt: job.prompt,
|
||||
model: "gemini-3-pro-image-preview",
|
||||
apiKey,
|
||||
aspectRatio: job.aspectRatio,
|
||||
contentType: job.contentType,
|
||||
});
|
||||
|
||||
const fileName =
|
||||
job.fileName ??
|
||||
`${String(index + 1).padStart(3, "0")}_${sanitizeName(job.internalRef ?? job.name ?? "asset")}${extensionFromMime(
|
||||
generated.contentType,
|
||||
)}`;
|
||||
const localPath = path.join(outDir, fileName);
|
||||
await writeFile(localPath, Buffer.from(generated.base64, "base64"));
|
||||
|
||||
const uploaded = await uploadImage({
|
||||
base64: generated.base64,
|
||||
fileName,
|
||||
contentType: generated.contentType,
|
||||
uploadUrl,
|
||||
});
|
||||
|
||||
results.push({
|
||||
internalRef: job.internalRef ?? "",
|
||||
type: job.type ?? "",
|
||||
promptName: job.promptName ?? job.name ?? "",
|
||||
fileName,
|
||||
localPath,
|
||||
contentType: generated.contentType,
|
||||
publicUrl: uploaded.url,
|
||||
uploadRawResponse: uploaded.raw,
|
||||
jimengUploadName: job.jimengUploadName ?? path.parse(fileName).name,
|
||||
referenceDuty: job.referenceDuty ?? "",
|
||||
status: uploaded.url ? "uploaded" : "uploaded_url_unparsed",
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(manifestPath, `${JSON.stringify(results, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote ${results.length} uploaded asset records to ${manifestPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user