Image2 4K 鐢熷浘宸ヤ綔鍙?/title>
<style>
:root {
color-scheme: light;
font-family:
Inter, "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--bg: #eef5f2;
--panel: #ffffff;
--line: #d8e2de;
--text: #17231f;
--muted: #66736e;
--green: #1e7c69;
--green-dark: #176352;
--green-soft: #dff1eb;
--orange: #b96a22;
--red: #b83a24;
--red-soft: #fff0ec;
--shadow: 0 18px 42px rgba(25, 48, 40, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app {
width: min(1440px, 100%);
margin: 0 auto;
padding: 18px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.brand h1 {
margin: 0;
font-size: 20px;
}
.brand p {
margin: 3px 0 0;
color: var(--muted);
font-size: 13px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.status {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: #f8fbfa;
color: var(--muted);
white-space: nowrap;
}
.status.ready {
color: var(--green);
background: var(--green-soft);
border-color: #9fd3c3;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--text);
padding: 0 14px;
font-weight: 700;
}
.button.primary {
border-color: var(--green);
background: var(--green);
color: #fff;
}
.button.primary:hover {
background: var(--green-dark);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.workspace {
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 18px;
margin-top: 18px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.controls {
padding: 18px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.panel-head h2 {
margin: 0;
font-size: 18px;
}
.panel-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
label span,
.label {
display: block;
margin-bottom: 7px;
color: #263832;
font-weight: 700;
font-size: 13px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #cfdad6;
border-radius: 8px;
background: #fbfdfc;
color: var(--text);
outline: none;
}
input,
select {
height: 42px;
padding: 0 12px;
}
textarea {
min-height: 150px;
resize: vertical;
padding: 12px;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--green);
box-shadow: 0 0 0 3px rgba(30, 124, 105, 0.13);
}
.composer {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: #f8fbfa;
}
.composer.dragging {
border-color: var(--green);
background: var(--green-soft);
}
.composer textarea {
border: 0;
background: transparent;
box-shadow: none;
padding: 6px;
}
.composer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 2px 0;
}
.composer-tools {
display: flex;
align-items: center;
gap: 8px;
}
.icon-button {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.upload-count {
color: var(--muted);
font-size: 13px;
}
.refs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.ref {
position: relative;
margin: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.ref img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.ref button {
position: absolute;
top: 4px;
right: 4px;
width: 26px;
height: 26px;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 14px;
}
.section {
margin-top: 16px;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
margin-bottom: 10px;
}
.section-title h3 {
margin: 0;
font-size: 15px;
}
.section-title span {
color: var(--muted);
font-size: 13px;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.preset {
min-height: 76px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfdfc;
padding: 10px;
text-align: left;
}
.preset strong,
.preset span,
.preset small {
display: block;
}
.preset span,
.preset small {
color: var(--muted);
}
.preset.selected {
border-color: var(--green);
background: var(--green-soft);
box-shadow: inset 0 0 0 1px var(--green);
}
.toggle-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-weight: 700;
}
.toggle-line input {
width: 16px;
height: 16px;
}
.hint,
.error {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 12px 0 0;
padding: 11px 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.45;
white-space: pre-line;
}
.hint {
color: var(--orange);
background: #fff6e9;
}
.error {
color: var(--red);
background: var(--red-soft);
}
.generate {
width: 100%;
margin-top: 16px;
height: 48px;
font-size: 16px;
}
.results {
min-height: 640px;
padding: 18px;
}
.result-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.result-top h2 {
margin: 0;
font-size: 18px;
}
.result-top p {
margin: 4px 0 0;
color: var(--muted);
}
.empty,
.loading {
display: grid;
place-items: center;
min-height: 420px;
color: var(--muted);
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #cfe1da;
border-top-color: var(--green);
border-radius: 50%;
animation: spin 0.9s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.card {
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfdfc;
overflow: hidden;
}
.preview {
display: grid;
place-items: center;
min-height: 260px;
background: #eef3f1;
}
.preview img {
width: 100%;
height: 100%;
max-height: 620px;
object-fit: contain;
display: block;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px;
}
.meta strong,
.meta span {
display: block;
}
.meta span {
color: var(--muted);
font-size: 13px;
}
.history {
margin-top: 18px;
}
.history h3 {
margin: 0 0 10px;
font-size: 15px;
}
.history-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.history-item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px;
background: #fff;
}
.history-item img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
background: #eef3f1;
border-radius: 6px;
}
.modal {
position: fixed;
inset: 0;
display: none;
place-items: center;
padding: 18px;
background: rgba(15, 24, 21, 0.46);
z-index: 20;
}
.modal.open {
display: grid;
}
.dialog {
width: min(520px, 100%);
background: #fff;
border-radius: 8px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 18px;
}
.dialog-head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.dialog-head h2 {
margin: 0;
font-size: 18px;
}
.dialog-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 13px;
}
.stack {
display: grid;
gap: 12px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.hidden {
display: none;
}
@media (max-width: 940px) {
.workspace {
grid-template-columns: 1fr;
}
.topbar,
.top-actions,
.result-top,
.meta {
align-items: stretch;
flex-direction: column;
}
}
</style>
</head>
<body>
<main class="app">
<header class="topbar">
<div class="brand">
<h1>Image2 4K 鐢熷浘宸ヤ綔鍙?/h1>
<p>鍏綉鐩磋繛鐗?- 閫傚悎鍙戠粰寮傚湴鍚屼簨</p>
</div>
<div class="top-actions">
<span id="status" class="status">鏈厤缃?API</span>
<button id="openApi" class="button" type="button">娣诲姞 API</button>
</div>
</header>
<section class="workspace">
<aside class="panel controls">
<div class="panel-head">
<div>
<h2>鐢熸垚鍙傛暟</h2>
<p id="modelLabel">gpt-image-2</p>
</div>
</div>
<label>
<span>鎻愮ず璇?/span>
<div id="composer" class="composer">
<textarea id="prompt" placeholder="鎻忚堪鎴栫紪杈戝浘鐗?>涓€寮犳湭鏉ユ劅浜у搧娴锋姤锛岄€忔槑鐜荤拑鏉愯川鐨勬櫤鑳藉奖鍍忚澶囷紝绮惧瘑宸ヤ笟璁捐锛屾憚褰辨甯冨厜锛岀粏鑺備赴瀵?/textarea>
<div id="refs" class="refs"></div>
<div class="composer-bar">
<div class="composer-tools">
<button id="pickImage" class="icon-button" type="button" title="涓婁紶鍥剧墖">+</button>
<button id="pickImageText" class="button" type="button">鍥剧墖</button>
<span id="uploadCount" class="upload-count">0/4</span>
</div>
<button id="copyPrompt" class="icon-button" type="button" title="澶嶅埗鎻愮ず璇?>猝?/button>
</div>
<input id="fileInput" class="hidden" type="file" accept="image/*" multiple />
</div>
</label>
<div class="grid2">
<label>
<span>妯″瀷</span>
<input id="modelInput" value="gpt-image-2" />
</label>
<label>
<span>鏁伴噺</span>
<select id="countInput">
<option value="1">1 寮?/option>
<option value="2">2 寮?/option>
<option value="3">3 寮?/option>
<option value="4">4 寮?/option>
</select>
</label>
</div>
<section class="section">
<div class="section-title">
<h3>娓呮櫚搴?/h3>
<span id="qualityLabel">鍘熺敓灏哄</span>
</div>
<div class="grid2">
<button id="nativeQuality" class="button primary" type="button">鍘熺敓娓呮櫚</button>
<button id="fastQuality" class="button" type="button">蹇€熺敓鎴?/button>
</div>
<p id="qualityHint" class="hint">涓婃父鎸夊師鐢熷昂瀵稿嚭鍥撅紝娓呮櫚搴︽洿濂斤紝浣?4K 鍙兘闇€瑕佹洿涔呫€?/p>
</section>
<section class="section">
<div class="section-title">
<h3>灏哄瑙勬牸</h3>
<span id="activeSizeLabel">1024x1024</span>
</div>
<div id="presetGrid" class="preset-grid"></div>
<label class="toggle-line">
<input id="customToggle" type="checkbox" />
鑷畾涔夊嚭鍥惧昂瀵? </label>
<div class="grid2">
<label>
<span>瀹藉害</span>
<input id="widthInput" type="number" min="64" max="3840" step="8" value="3840" />
</label>
<label>
<span>楂樺害</span>
<input id="heightInput" type="number" min="64" max="3840" step="8" value="2160" />
</label>
</div>
<p id="sizeError" class="error hidden"></p>
</section>
<p id="errorBox" class="error hidden"></p>
<button id="generateButton" class="button primary generate" type="button">寮€濮嬬敓鎴?/button>
</aside>
<section class="panel results">
<div class="result-top">
<div>
<h2>鐢熸垚缁撴灉</h2>
<p id="resultHint">1024x1024 - 鐢熸垚鍚庡彲涓嬭浇鍥剧墖</p>
</div>
<button id="clearResults" class="button" type="button">娓呯┖缁撴灉</button>
</div>
<div id="loading" class="loading hidden">
<div>
<div class="spinner"></div>
<p>姝e湪鐢熸垚鍥剧墖锛?K 鍙兘闇€瑕佸嚑鍒嗛挓锛岃涓嶈閲嶅鎻愪氦銆?/p>
</div>
</div>
<div id="empty" class="empty">娣诲姞 API 鍚庤緭鍏ユ彁绀鸿瘝鎴栦笂浼犲浘鐗囷紝閫夋嫨瑙勬牸鍗冲彲鍑哄浘銆?/div>
<div id="imageGrid" class="image-grid"></div>
<section id="history" class="history hidden">
<h3>鍘嗗彶缁撴灉</h3>
<div id="historyRow" class="history-row"></div>
</section>
</section>
</section>
</main>
<div id="apiModal" class="modal">
<form id="apiForm" class="dialog">
<div class="dialog-head">
<div>
<h2>娣诲姞 API</h2>
<p>閰嶇疆鍙繚瀛樺湪褰撳墠娴忚鍣ㄤ細璇濆唴</p>
</div>
<button id="closeApi" class="icon-button" type="button">脳</button>
</div>
<div class="stack">
<label>
<span>Base URL</span>
<input id="baseUrlInput" value="https://nyue.cc/v1/images/generations" />
</label>
<label>
<span>API Key</span>
<input id="apiKeyInput" type="password" placeholder="sk-..." />
</label>
<label>
<span>妯″瀷</span>
<input id="apiModelInput" value="gpt-image-2" />
</label>
<p id="connectionBox" class="hint hidden"></p>
</div>
<div class="dialog-actions">
<button id="testApi" class="button" type="button">娴嬭瘯杩炴帴</button>
<button class="button primary" type="submit">淇濆瓨閰嶇疆</button>
</div>
</form>
</div>
<script>
const defaults = {
baseUrl: "https://nyue.cc/v1/images/generations",
apiKey: "",
model: "gpt-image-2"
};
const sizeRule = { min: 64, max: 3840, step: 8, maxPixels: 3840 * 2160, maxSquare: 2880 };
const presets = [
["1:1", "1024x1024", "1024"],
["16:9", "1792x1024", "wide"],
["9:16", "1024x1792", "vertical"],
["4:3", "1600x1200", "landscape"],
["3:4", "1200x1600", "poster"],
["4K 妯浘", "3840x2160", "UHD"],
["4K 绔栧浘", "2160x3840", "UHD"],
["4K 鏂瑰浘", "2880x2880", "max square"]
];
let apiConfig = null;
let selectedSize = "1024x1024";
let qualityMode = "native";
let references = [];
let results = loadResults();
let isGenerating = false;
const $ = (id) => document.getElementById(id);
const els = {
status: $("status"),
openApi: $("openApi"),
apiModal: $("apiModal"),
apiForm: $("apiForm"),
closeApi: $("closeApi"),
baseUrl: $("baseUrlInput"),
apiKey: $("apiKeyInput"),
apiModel: $("apiModelInput"),
modelInput: $("modelInput"),
modelLabel: $("modelLabel"),
prompt: $("prompt"),
composer: $("composer"),
refs: $("refs"),
uploadCount: $("uploadCount"),
pickImage: $("pickImage"),
pickImageText: $("pickImageText"),
fileInput: $("fileInput"),
copyPrompt: $("copyPrompt"),
count: $("countInput"),
nativeQuality: $("nativeQuality"),
fastQuality: $("fastQuality"),
qualityLabel: $("qualityLabel"),
qualityHint: $("qualityHint"),
presetGrid: $("presetGrid"),
customToggle: $("customToggle"),
width: $("widthInput"),
height: $("heightInput"),
activeSize: $("activeSizeLabel"),
sizeError: $("sizeError"),
error: $("errorBox"),
generate: $("generateButton"),
loading: $("loading"),
empty: $("empty"),
imageGrid: $("imageGrid"),
history: $("history"),
historyRow: $("historyRow"),
resultHint: $("resultHint"),
clearResults: $("clearResults"),
connection: $("connectionBox"),
testApi: $("testApi")
};
init();
function init() {
renderPresets();
bindEvents();
syncUi();
renderReferences();
renderResults();
}
function bindEvents() {
els.openApi.addEventListener("click", openApiDialog);
els.closeApi.addEventListener("click", closeApiDialog);
els.apiModal.addEventListener("click", (event) => {
if (event.target === els.apiModal) closeApiDialog();
});
els.apiForm.addEventListener("submit", saveApi);
els.testApi.addEventListener("click", testApi);
els.pickImage.addEventListener("click", () => els.fileInput.click());
els.pickImageText.addEventListener("click", () => els.fileInput.click());
els.fileInput.addEventListener("change", () => {
addFiles(els.fileInput.files);
els.fileInput.value = "";
});
els.copyPrompt.addEventListener("click", () => navigator.clipboard?.writeText(els.prompt.value.trim()));
els.modelInput.addEventListener("input", () => {
if (apiConfig) apiConfig.model = els.modelInput.value.trim() || defaults.model;
els.modelLabel.textContent = els.modelInput.value.trim() || defaults.model;
});
els.customToggle.addEventListener("change", syncUi);
els.width.addEventListener("input", () => {
els.customToggle.checked = true;
syncUi();
});
els.height.addEventListener("input", () => {
els.customToggle.checked = true;
syncUi();
});
els.nativeQuality.addEventListener("click", () => setQuality("native"));
els.fastQuality.addEventListener("click", () => setQuality("fast"));
els.generate.addEventListener("click", generate);
els.clearResults.addEventListener("click", () => {
results = [];
saveResults();
renderResults();
});
els.composer.addEventListener("paste", handlePaste);
els.composer.addEventListener("dragenter", () => els.composer.classList.add("dragging"));
els.composer.addEventListener("dragleave", () => els.composer.classList.remove("dragging"));
els.composer.addEventListener("dragover", (event) => event.preventDefault());
els.composer.addEventListener("drop", handleDrop);
}
function renderPresets() {
els.presetGrid.innerHTML = "";
presets.forEach(([label, size, note]) => {
const button = document.createElement("button");
button.className = "preset";
button.type = "button";
button.dataset.size = size;
button.innerHTML = `<strong>${label}</strong><span>${size}</span><small>${note}</small>`;
button.addEventListener("click", () => {
selectedSize = size;
els.customToggle.checked = false;
const [w, h] = parseSize(size);
els.width.value = w;
els.height.value = h;
syncUi();
});
els.presetGrid.appendChild(button);
});
}
function openApiDialog() {
els.baseUrl.value = apiConfig?.baseUrl || defaults.baseUrl;
els.apiKey.value = apiConfig?.apiKey || "";
els.apiModel.value = apiConfig?.model || els.modelInput.value.trim() || defaults.model;
setConnection("");
els.apiModal.classList.add("open");
}
function closeApiDialog() {
els.apiModal.classList.remove("open");
}
function saveApi(event) {
event.preventDefault();
const next = {
baseUrl: normalizeBaseUrl(els.baseUrl.value),
apiKey: els.apiKey.value.trim(),
model: els.apiModel.value.trim() || defaults.model
};
if (!next.apiKey) {
setConnection("璇峰~鍐?API Key銆?, true);
return;
}
apiConfig = next;
els.modelInput.value = next.model;
els.modelLabel.textContent = next.model;
els.status.textContent = "API 宸查厤缃?;
els.status.classList.add("ready");
closeApiDialog();
}
async function testApi() {
try {
const config = {
baseUrl: normalizeBaseUrl(els.baseUrl.value),
apiKey: els.apiKey.value.trim(),
model: els.apiModel.value.trim() || defaults.model
};
if (!config.apiKey) throw new Error("璇峰~鍐?API Key銆?);
setConnection("姝e湪娴嬭瘯杩炴帴...");
const urls = resolveApiUrls(config.baseUrl);
const response = await fetch(urls.modelsUrl, {
headers: { Authorization: `Bearer ${config.apiKey}` }
});
const payload = await readJson(response);
if (!response.ok) throw new Error(extractError(payload) || `杩炴帴澶辫触锛岀姸鎬佺爜 ${response.status}`);
const models = Array.isArray(payload?.data) ? payload.data.map((item) => item?.id).filter(Boolean) : [];
const imageModels = models.filter((model) => /image|gpt-image/i.test(model));
const samples = (imageModels.length ? imageModels : models).slice(0, 6);
if (models.length && !models.includes(config.model)) {
setConnection(
`杩炴帴鎴愬姛锛屼絾妯″瀷鍒楄〃閲屾病鏈?${config.model}銆俓n璇峰湪鍚庡彴纭杩欎釜 API Key 鎵€灞炲垎缁勬湁璇ユā鍨嬮€氶亾锛屾垨鎶婃ā鍨嬫敼鎴愬彲鐢ㄦā鍨嬶細${samples.join("銆?)}`,
true
);
} else {
setConnection(samples.length ? `杩炴帴鎴愬姛锛屽彲鐢ㄦā鍨嬬ず渚嬶細${samples.join("銆?)}` : "杩炴帴鎴愬姛銆?);
}
} catch (error) {
setConnection(error.message || "杩炴帴娴嬭瘯澶辫触銆?, true);
}
}
function setConnection(text, isError = false) {
els.connection.textContent = text;
els.connection.classList.toggle("hidden", !text);
els.connection.classList.toggle("error", isError);
els.connection.classList.toggle("hint", !isError);
}
function setQuality(mode) {
qualityMode = mode;
els.nativeQuality.classList.toggle("primary", mode === "native");
els.fastQuality.classList.toggle("primary", mode === "fast");
syncUi();
}
function getActiveSize() {
if (els.customToggle.checked) {
return `${clampDimension(Number(els.width.value))}x${clampDimension(Number(els.height.value))}`;
}
return selectedSize;
}
function syncUi() {
const active = getActiveSize();
const upstream = qualityMode === "native" ? active : getFastGenerationSize(active);
els.activeSize.textContent = active;
els.resultHint.textContent = upstream === active ? `${active} - 鐢熸垚鍚庡彲涓嬭浇鍥剧墖` : `${upstream} 鐢熸垚 - ${active} 涓嬭浇瀵煎嚭`;
els.qualityLabel.textContent = qualityMode === "native" ? "鍘熺敓灏哄" : "蹇€熼瑙?;
els.qualityHint.textContent =
qualityMode === "native"
? `涓婃父鎸?${active} 鍘熺敓鍑哄浘锛屾竻鏅板害鏇村ソ锛屼絾 4K 鍙兘闇€瑕佹洿涔呫€俙
: `涓婃父鎸?${upstream} 蹇€熷嚭鍥撅紝涓嬭浇浼氬鍑烘垚 ${active}锛屼絾缁嗚妭浼氭湁鏀惧ぇ鎰熴€俙;
document.querySelectorAll(".preset").forEach((button) => {
button.classList.toggle("selected", !els.customToggle.checked && button.dataset.size === selectedSize);
});
const validation = validateSize(active);
els.sizeError.textContent = validation.message;
els.sizeError.classList.toggle("hidden", validation.ok);
els.generate.disabled = isGenerating || !validation.ok;
}
async function generate() {
clearError();
if (!apiConfig) {
openApiDialog();
showError("璇峰厛娣诲姞 API銆?);
return;
}
const prompt = els.prompt.value.trim();
if (!prompt) {
showError("璇峰厛濉啓鎻愮ず璇嶃€?);
return;
}
const activeSize = getActiveSize();
const validation = validateSize(activeSize);
if (!validation.ok) {
showError(validation.message);
return;
}
const upstreamSize = qualityMode === "native" ? activeSize : getFastGenerationSize(activeSize);
setGenerating(true);
try {
const payload = references.length
? await requestEdit(prompt, upstreamSize)
: await requestGeneration(prompt, upstreamSize);
const next = (payload.data || [])
.map((item, index) => toResult(item, index, prompt, activeSize, apiConfig.model))
.filter(Boolean);
if (!next.length) throw new Error("涓婃父娌℃湁杩斿洖鍙瑙堢殑鍥剧墖銆?);
results = [...next, ...results].slice(0, 24);
saveResults();
renderResults();
} catch (error) {
showError(error.message || "鍥剧墖鐢熸垚澶辫触銆?);
} finally {
setGenerating(false);
}
}
async function requestGeneration(prompt, size) {
const urls = resolveApiUrls(apiConfig.baseUrl);
const response = await fetch(urls.generationUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${apiConfig.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ model: apiConfig.model, prompt, size, n: Number(els.count.value) })
});
const payload = await readJson(response);
if (!response.ok) throw new Error(extractError(payload) || `鍥剧墖鐢熸垚澶辫触锛岀姸鎬佺爜 ${response.status}`);
return payload;
}
async function requestEdit(prompt, size) {
const urls = resolveApiUrls(apiConfig.baseUrl);
const formData = new FormData();
formData.set("model", apiConfig.model);
formData.set("prompt", prompt);
formData.set("size", size);
formData.set("n", els.count.value);
references.forEach((image, index) => {
formData.append("image", dataUrlToBlob(image.dataUrl), image.name || `reference-${index + 1}.png`);
});
const response = await fetch(urls.editUrl, {
method: "POST",
headers: { Authorization: `Bearer ${apiConfig.apiKey}` },
body: formData
});
const payload = await readJson(response);
if (!response.ok) throw new Error(extractError(payload) || `鍥剧墖缂栬緫澶辫触锛岀姸鎬佺爜 ${response.status}`);
return payload;
}
async function addFiles(fileList) {
const files = Array.from(fileList || []).filter((file) => file.type.startsWith("image/"));
if (!files.length) return showError("璇烽€夋嫨鍥剧墖鏂囦欢銆?);
const accepted = files.slice(0, 4 - references.length);
if (!accepted.length) return showError("鏈€澶氫笂浼?4 寮犲弬鑰冨浘銆?);
if (accepted.some((file) => file.size > 12 * 1024 * 1024)) return showError("鍗曞紶鍥剧墖涓嶈兘瓒呰繃 12MB銆?);
const next = await Promise.all(accepted.map(readFileAsDataUrl));
references = references.concat(next);
renderReferences();
}
function renderReferences() {
els.refs.innerHTML = "";
references.forEach((image) => {
const figure = document.createElement("figure");
figure.className = "ref";
figure.innerHTML = `<img src="${image.dataUrl}" alt="${escapeHtml(image.name)}"><button type="button">脳</button>`;
figure.querySelector("button").addEventListener("click", () => {
references = references.filter((item) => item.id !== image.id);
renderReferences();
});
els.refs.appendChild(figure);
});
els.uploadCount.textContent = `${references.length}/4`;
}
function handlePaste(event) {
const files = getImageFiles(event.clipboardData.items);
if (files.length) {
event.preventDefault();
addFiles(files);
return;
}
const text = event.clipboardData.getData("text/plain");
if (text.startsWith("data:image/")) {
event.preventDefault();
addFiles([dataUrlToFile(text)]);
}
}
function handleDrop(event) {
event.preventDefault();
els.composer.classList.remove("dragging");
const files = getImageFiles(event.dataTransfer.items);
if (files.length) {
addFiles(files);
return;
}
if (event.dataTransfer.files.length) addFiles(event.dataTransfer.files);
}
function renderResults() {
const count = Number(els.count.value);
const latest = results.slice(0, count);
const history = results.slice(count);
els.imageGrid.innerHTML = "";
els.historyRow.innerHTML = "";
els.empty.classList.toggle("hidden", isGenerating || latest.length > 0);
latest.forEach((result) => els.imageGrid.appendChild(createResultCard(result)));
history.forEach((result) => els.historyRow.appendChild(createHistoryItem(result)));
els.history.classList.toggle("hidden", history.length === 0);
}
function createResultCard(result) {
const card = document.createElement("article");
card.className = "card";
card.innerHTML = `
<div class="preview" style="aspect-ratio:${result.size.replace("x", "/")}">
<img src="${result.url}" alt="${escapeHtml(result.prompt)}">
</div>
<div class="meta">
<div><strong>${result.size}</strong><span>${escapeHtml(result.model)}</span></div>
<button class="button" type="button">涓嬭浇鍥剧墖</button>
</div>
`;
card.querySelector("button").addEventListener("click", () => downloadResult(result));
return card;
}
function createHistoryItem(result) {
const item = document.createElement("article");
item.className = "history-item";
item.innerHTML = `<img src="${result.url}" alt="${escapeHtml(result.prompt)}"><strong>${result.size}</strong><span>${result.createdAt}</span>`;
item.addEventListener("click", () => {
results = [result, ...results.filter((entry) => entry.id !== result.id)];
saveResults();
renderResults();
});
return item;
}
async function downloadResult(result) {
try {
const [width, height] = parseSize(result.size);
const blob = await fetchResultBlob(result.url);
const image = await loadImage(blob);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
context.fillStyle = "#fff";
context.fillRect(0, 0, width, height);
const scale = Math.min(width / image.naturalWidth, height / image.naturalHeight);
const drawWidth = image.naturalWidth * scale;
const drawHeight = image.naturalHeight * scale;
context.drawImage(image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight);
const out = await canvasBlob(canvas);
triggerDownload(out, `gpt-image-2-${result.size}-${result.id}.png`);
} catch (error) {
showError(error.message || "涓嬭浇鍥剧墖澶辫触銆傚鏋滅粨鏋滄槸澶栭摼涓旂姝㈣法鍩燂紝鍙互鍙抽敭棰勮鍥惧彟瀛樹负銆?);
}
}
function setGenerating(next) {
isGenerating = next;
els.loading.classList.toggle("hidden", !next);
els.generate.textContent = next ? "鐢熸垚涓紝璇风◢绛? : "寮€濮嬬敓鎴?;
syncUi();
renderResults();
}
function showError(text) {
els.error.textContent = text;
els.error.classList.remove("hidden");
}
function clearError() {
els.error.textContent = "";
els.error.classList.add("hidden");
}
function normalizeBaseUrl(value) {
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed || trimmed === "http://154.201.76.123:3000") return defaults.baseUrl;
if (trimmed === "http://154.201.76.123:3000/v1/images/generations") return defaults.baseUrl;
return trimmed;
}
function resolveApiUrls(value) {
const url = new URL(normalizeBaseUrl(value));
const path = url.pathname.replace(/\/+$/, "");
const v1Index = path.indexOf("/v1");
if (v1Index >= 0) {
const prefix = `${url.origin}${path.slice(0, v1Index)}/v1`;
return {
modelsUrl: `${prefix}/models`,
generationUrl: `${prefix}/images/generations`,
editUrl: `${prefix}/images/edits`
};
}
const root = `${url.origin}${path}`.replace(/\/+$/, "");
return {
modelsUrl: `${root}/v1/models`,
generationUrl: `${root}/v1/images/generations`,
editUrl: `${root}/v1/images/edits`
};
}
function parseSize(size) {
return size.split("x").map((value) => clampDimension(Number(value)));
}
function clampDimension(value) {
if (!Number.isFinite(value)) return 1024;
const clamped = Math.min(sizeRule.max, Math.max(sizeRule.min, Math.round(value)));
return Math.round(clamped / sizeRule.step) * sizeRule.step;
}
function validateSize(size) {
const [width, height] = parseSize(size);
if (width < sizeRule.min || height < sizeRule.min || width > sizeRule.max || height > sizeRule.max) {
return { ok: false, message: `image-2 灏哄瑕佹眰瀹介珮鍦?${sizeRule.min}-${sizeRule.max}px 鍐呫€俙 };
}
if (width % sizeRule.step || height % sizeRule.step) {
return { ok: false, message: `image-2 灏哄闇€瑕佹寜 ${sizeRule.step}px 姝ヨ繘銆俙 };
}
if (width * height > sizeRule.maxPixels) {
return {
ok: false,
message: `${width}x${height} 瓒呭嚭 image-2 褰撳墠鍍忕礌棰勭畻锛涜鏀圭敤 3840x2160銆?160x3840锛屾垨鏂瑰浘鏈€澶?${sizeRule.maxSquare}x${sizeRule.maxSquare}銆俙
};
}
return { ok: true, message: "" };
}
function getFastGenerationSize(size) {
const [width, height] = parseSize(size);
if (width <= 1536 && height <= 1536) return size;
const ratio = width / height;
if (ratio > 1.18) return "1536x1024";
if (ratio < 0.85) return "1024x1536";
return "1024x1024";
}
function toResult(item, index, prompt, size, model) {
const url = item.url || (item.b64_json ? `data:image/png;base64,${item.b64_json}` : "");
if (!url) return null;
return {
id: `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`,
url,
prompt: item.revised_prompt || prompt,
size,
model,
createdAt: new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(new Date())
};
}
async function readJson(response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
}
function extractError(payload) {
if (!payload) return "";
const message =
typeof payload.error === "string"
? payload.error
: payload.error?.message
? payload.error.message
: payload.message || "";
return formatGatewayError(message);
}
function formatGatewayError(message) {
if (typeof message !== "string" || !message.trim()) return "";
const noChannel = /No available channel for model\s+(.+?)\s+under group\s+(.+?)(?:\s|\(|$)/i.exec(message);
if (noChannel) {
const requestId = /\(request id:\s*([^)]+)\)/i.exec(message)?.[1];
return [
`褰撳墠 API Key 鎵€灞炲垎缁?${noChannel[2]} 娌℃湁鍙敤鐨?${noChannel[1]} 鐢熷浘閫氶亾銆俙,
"瑙e喅鍔炴硶锛氬埌 New API 鍚庡彴缁欒鍒嗙粍鍚敤/缁戝畾鏀寔杩欎釜妯″瀷鐨勬笭閬擄紝鎴栨崲涓€涓湁璇ユā鍨嬫潈闄愮殑 API Key銆?,
"濡傛灉鍚庡彴妯″瀷鍚嶄笉鏄?gpt-image-2锛屽氨鍦ㄥ彸涓婅鈥滄坊鍔?API鈥濋噷鎶婃ā鍨嬪悕鏀规垚鍚庡彴瀹為檯鍙敤鐨勫悕绉般€?,
requestId ? `璇锋眰 ID锛?{requestId}` : ""
]
.filter(Boolean)
.join("\n");
}
if (/system cpu overloaded/i.test(message)) {
return `${message}\n涓婃父鏈嶅姟鍣ㄥ綋鍓嶈繃杞斤紝璇风◢鍚庡啀璇曪紝鎴栧厛闄嶄綆灏哄/鏁伴噺銆俙;
}
return message;
}
function getImageFiles(items) {
return Array.from(items || [])
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter(Boolean);
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
name: file.name,
size: file.size,
type: file.type,
dataUrl: reader.result
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function dataUrlToBlob(dataUrl) {
const [header, base64] = dataUrl.split(",");
const type = /data:([^;]+)/.exec(header)?.[1] || "image/png";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return new Blob([bytes], { type });
}
function dataUrlToFile(dataUrl) {
return new File([dataUrlToBlob(dataUrl)], `pasted-${Date.now()}.png`, { type: "image/png" });
}
async function fetchResultBlob(url) {
const response = await fetch(url);
if (!response.ok) throw new Error("鏃犳硶璇诲彇鐢熸垚鍥剧墖銆?);
return response.blob();
}
function loadImage(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("鍥剧墖瑙g爜澶辫触銆?));
};
image.src = url;
});
}
function canvasBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("鍥剧墖瀵煎嚭澶辫触銆?))), "image/png");
});
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function loadResults() {
try {
const parsed = JSON.parse(localStorage.getItem("image2-direct-results") || "[]");
return Array.isArray(parsed) ? parsed.slice(0, 50) : [];
} catch {
return [];
}
}
function saveResults() {
try {
localStorage.setItem("image2-direct-results", JSON.stringify(results.filter((item) => item.url.length < 200000).slice(0, 50)));
} catch {}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
}
</script>
<!-- publish-version:1780638219 -->
<!--__BP_ANCHORS__-->