ChatGPT Image 2.0生图工具核心JS实现
这篇文章只讲本项目里“ChatGPT Image 2.0生图”工具的功能 JS 实现。工具用 Vue 管理页面状态,核心链路可以概括为: 它不只是一个文本生图表单,还支持参考图生图、图片编辑、变体生成、遮罩编辑、流式预览和本地历史记录。 工具内部用 提交时先根据模式构造 payload,再决定请求入口: 这样做的好处是页面交互可以共用一套状态,但参数校验和接口调用仍然按模式拆开。 生图、编辑、参考图模式都有一批公共字段,例如模型、提示词、数量、尺寸、质量、背景、输出格式、压缩比例等。实现里用 这里的 参考图、编辑图、遮罩图和变体图都来自文件输入。工具没有直接保存原始 转换后的图片对象结构稳定,后续无论是展示缩略图、提交参数,还是做变体图尺寸校验,都可以复用。 编辑模式要求有提示词和图片输入。图片来源分两种:本地上传的图片,或者用户输入的远程图片引用。实现里明确禁止两种来源混用: 编辑 payload 会在公共参数基础上追加 编辑模式支持在图片上涂抹遮罩。实现上准备了一个隐藏绘制层 生成遮罩时,会先创建一张白色画布,再把涂抹过的位置改成透明,最后导出 PNG Data URL: 这段逻辑的关键点是:用户看到的是红色涂抹层,真正提交的是白底透明区域的 PNG 遮罩。 非流式响应返回完整 JSON,工具会把 流式响应则通过 解析出来的数据如果是中间图,就放入 生成完成后,工具会把结果写入 IndexedDB。记录里包含生成时间、模式、提示词、模型、图片数组和用量信息。 历史记录按时间倒序读取,并限制最多保留 60 条。这样用户可以恢复之前的生成结果,同时不会让本地记录无限增长。 这个工具的核心 JS 并不复杂,关键在于把多种图片生成场景拆成清晰的数据流:公共参数统一生成,图片输入统一转换,编辑模式独立处理遮罩,结果解析统一转成前端可展示的图片对象。Vue 只负责把这些状态串起来,真正的功能逻辑都围绕 payload 构造、图片转换、响应解析和历史记录展开。选择模式 -> 组装参数 -> 处理图片输入 -> 发起请求 -> 解析图片结果 -> 保存历史记录在线工具网址:https://see-tool.com/chatgpt-image-2-generator
工具截图:
1)用 mode 区分不同生图流程
mode 控制当前操作类型,默认是 generate。不同模式最终会走不同的参数构造逻辑:generate:纯文本生图image:带参考图的生图edit:上传图片后按提示词编辑variation:基于 PNG 方图生成变体submit: function () {
var payload;
var endpoint;
if (this.mode === "generate") {
payload = this.generationPayload();
endpoint = "/api/chatgpt-image-2/generate";
} else if (this.mode === "variation") {
payload = this.variationPayload();
endpoint = "/api/chatgpt-image-2/variation";
} else {
payload = this.editPayload();
endpoint = "/api/chatgpt-image-2/edit";
}
this.pendingPayload = payload;
this.pendingEndpoint = endpoint;
}2)公共参数统一由 commonPayload 生成
commonPayload 集中处理这些字段。commonPayload: function () {
var outputFormat = this.format;
var payload = cleanObject({
model: "gpt-image-2",
prompt: String(this.prompt || "").trim(),
n: this.parseInteger(this.count, 1, 1, 10),
size: this.size,
quality: this.quality,
background: this.background,
output_format: outputFormat,
moderation: this.moderation,
user: String(this.userId || "").trim(),
stream: this.stream && this.mode !== "variation" ? true : undefined,
partial_images: this.stream && this.mode !== "variation" ? this.parseInteger(this.partials, 2, 0, 3) : undefined,
});
if (outputFormat === "jpeg" || outputFormat === "webp") {
payload.output_compression = this.parseInteger(this.compression, 100, 0, 100);
}
return Object.assign({}, payload, this.parseExtraJson());
}cleanObject 会移除空字符串、null 和 undefined,避免把无效字段传给后端。parseInteger 则负责把用户输入转成安全范围内的数字。3)图片输入先转成统一结构
File,而是用 FileReader 转成 Data URL,并额外读取图片宽高。fileToDataUrl: function (file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () {
var dataUrl = reader.result;
var image = new Image();
image.onload = function () {
resolve({
id: createId(),
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
width: image.naturalWidth,
height: image.naturalHeight,
dataUrl: dataUrl,
});
};
image.src = dataUrl;
};
reader.onerror = function () {
reject(reader.error);
};
reader.readAsDataURL(file);
});
}4)编辑模式的 payload 会合并图片和遮罩
apiSafeImageList: function (uploads, refs) {
if (uploads.length && refs.length) {
throw new Error(this.t("chatgptImage2Generator.messages.mixedImages"));
}
return uploads.length ? uploads : refs;
}images,如果用户启用了遮罩,则再追加 mask:editPayload: function () {
var payload = this.commonPayload();
var uploads = this.mode === "image" ? this.imageInputs : this.editInputs;
var refs = this.mode === "image" ? this.imageReferencesFrom(this.imageRefs) : this.imageReferencesFrom(this.editRefs);
var mask = this.imageReference(this.maskRef) || this.selectedMask();
if (!payload.prompt) throw new Error(this.t("chatgptImage2Generator.messages.promptRequired"));
payload.images = this.apiSafeImageList(uploads, refs);
if (!payload.images.length) throw new Error(this.t("chatgptImage2Generator.messages.imageRequired"));
if (this.inputFidelity) payload.input_fidelity = this.inputFidelity;
if (this.mode === "edit" && (this.useMask || this.maskRef.trim()) && mask) {
payload.mask = mask;
}
return payload;
}5)遮罩绘制用 Canvas 生成透明区域
maskPaintCanvas,用户在可见画布上拖动时,会把笔刷圆形画到绘制层。buildMaskDataUrl: function () {
var source = this.maskPaintCanvas;
var mask = document.createElement("canvas");
mask.width = source.width;
mask.height = source.height;
var maskCtx = mask.getContext("2d");
maskCtx.fillStyle = "#ffffff";
maskCtx.fillRect(0, 0, mask.width, mask.height);
var paint = source.getContext("2d").getImageData(0, 0, source.width, source.height);
var output = maskCtx.getImageData(0, 0, mask.width, mask.height);
for (var i = 0; i < paint.data.length; i += 4) {
if (paint.data[i + 3] > 0) output.data[i + 3] = 0;
}
maskCtx.putImageData(output, 0, 0);
return mask.toDataURL("image/png");
}6)普通响应和流式响应分开解析
b64_json 或 url 统一整理成图片对象:normalizeImages: function (response, payload) {
var format = response.output_format || payload.output_format || "png";
var data = Array.isArray(response.data) ? response.data : [];
return data
.map(function (item, index) {
var src = item.b64_json ? dataUrlFromB64(item.b64_json, format) : item.url;
if (!src) return null;
return {
id: createId(),
src: src,
format: format,
revisedPrompt: item.revised_prompt || "",
index: index,
};
})
.filter(Boolean);
}ReadableStream 逐段读取,再按 SSE 的空行边界拆块:parseSseBlocks: function (buffer) {
var blocks = [];
var boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
blocks.push(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
}
return { blocks: blocks, rest: buffer };
}partialImages;如果是完成图,就放入 resultImages。7)本地历史记录使用 IndexedDB
saveResultRecord: async function (images, payload, mode, response) {
await this.saveHistoryRecord({
id: createId(),
createdAt: Date.now(),
mode: mode,
prompt: payload.prompt || "",
model: payload.model || (mode === "variation" ? "dall-e-2" : "gpt-image-2"),
images: images,
usage: response.usage || null,
});
}8)核心实现总结