feat: AI炫图接入gpt-image-2.5云端生图(结构化提示词工程+照片参考合成)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// 云端 AI 炫图(gpt-image)端到端验证: 注入测试照片 → 拖框 → AI 照片合成 → 截图
|
||||
// 用法: node verify-aicloud.mjs <token>
|
||||
import puppeteer from 'puppeteer-core'
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
headless: 'new',
|
||||
args: ['--window-size=1440,900', '--enable-unsafe-swiftshader'],
|
||||
defaultViewport: { width: 1440, height: 900 },
|
||||
})
|
||||
const page = await browser.newPage()
|
||||
page.on('pageerror', e => console.log(`[JS异常] ${String(e).slice(0, 200)}`))
|
||||
const token = process.argv[2]
|
||||
await page.goto(`http://localhost:8000/ai_render?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' })
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
|
||||
// 1) 选第一个样板
|
||||
await page.click('.controls .el-select')
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
await (await page.$('.el-select-dropdown__item')).click()
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
// 2) 注入程序化"旧客厅"照片(带深色旧窗洞)
|
||||
await page.evaluate(() => {
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = 1200; cv.height = 800
|
||||
const ctx = cv.getContext('2d')
|
||||
const g = ctx.createLinearGradient(0, 0, 0, 800)
|
||||
g.addColorStop(0, '#e8e0d2'); g.addColorStop(0.7, '#d8cdbb'); g.addColorStop(1, '#9c8b74')
|
||||
ctx.fillStyle = g; ctx.fillRect(0, 0, 1200, 800)
|
||||
ctx.fillStyle = 'rgba(120,100,80,0.25)'
|
||||
ctx.fillRect(380, 260, 440, 340)
|
||||
ctx.strokeStyle = 'rgba(90,70,50,0.5)'; ctx.lineWidth = 8
|
||||
ctx.strokeRect(380, 260, 440, 340)
|
||||
return new Promise(resolve => {
|
||||
cv.toBlob(blob => {
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File([blob], 'room.png', { type: 'image/png' }))
|
||||
const input = document.querySelector('.el-upload input[type=file]')
|
||||
input.files = dt.files
|
||||
input.dispatchEvent(new Event('change'))
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
|
||||
// 3) 框选窗洞
|
||||
const stage = await page.$('.photo-cv')
|
||||
const b = await stage.boundingBox()
|
||||
await page.mouse.move(b.x + b.width * 0.3, b.y + b.height * 0.3)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width * 0.7, b.y + b.height * 0.75, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// 4) 切到"室外渲染"场景更像安装效果: 保持默认渲图, 点击 AI 照片合成
|
||||
const genBtn = await page.evaluateHandle(() => [...document.querySelectorAll('button')].find(x => x.textContent.includes('AI 照片合成')))
|
||||
await genBtn.asElement().click()
|
||||
console.log('已提交云端生成, 等待 AI 绘制(最长 150s)...')
|
||||
// 轮询等 ai-img 出现
|
||||
let ok = false
|
||||
for (let i = 0; i < 75; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
ok = await page.evaluate(() => !!document.querySelector('.ai-img'))
|
||||
if (ok) break
|
||||
if (i % 5 === 4) console.log(` 等待中 ${(i + 1) * 2}s`)
|
||||
}
|
||||
if (!ok) { console.log('✗ 超时未返回'); await browser.close(); process.exit(1) }
|
||||
const img = await page.$('.ai-result')
|
||||
await img.screenshot({ path: 'D:/Code/huamenchuang/shots/verify-aicloud.png' })
|
||||
console.log('✓ 云端 AI 照片合成验证完成')
|
||||
await browser.close()
|
||||
@@ -36,9 +36,9 @@ http.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
/** 提取 envelope 的 data */
|
||||
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
|
||||
const res = await http.request({ method, url, data: body })
|
||||
/** 提取 envelope 的 data(config 可传 timeout 等覆盖) */
|
||||
export async function api<T = any>(method: string, url: string, body?: object, config?: object): Promise<T> {
|
||||
const res = await http.request({ method, url, data: body, ...config })
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
<el-upload :show-file-list="false" accept="image/*" :http-request="onUpload">
|
||||
<el-button type="primary" plain>{{ photo ? '更换照片' : '上传场景照片' }}</el-button>
|
||||
</el-upload>
|
||||
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating" @click="generate">
|
||||
{{ generating ? '合成中…' : '✦ 生成炫图' }}</el-button>
|
||||
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating"
|
||||
@click="generateCloud('photo')">
|
||||
{{ generating ? 'AI 合成中…' : '✦ AI 照片合成' }}</el-button>
|
||||
</div>
|
||||
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
|
||||
<el-icon size="40"><UploadFilled /></el-icon>
|
||||
@@ -67,8 +68,9 @@
|
||||
<el-select v-model="form.scene" style="width:110px">
|
||||
<el-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateQuick">
|
||||
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
|
||||
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateCloud('quick')">
|
||||
{{ generating ? 'AI 绘制中…' : '✦ AI 云端生成' }}</el-button>
|
||||
<el-button :disabled="!drawing" :loading="generating" @click="generateQuick">本地 4 方案</el-button>
|
||||
</div>
|
||||
|
||||
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
|
||||
@@ -82,7 +84,21 @@
|
||||
</template>
|
||||
|
||||
<div v-if="variants.length" class="gallery-foot">
|
||||
<span class="tip">点击方案切换大图预览,图标下载 PNG</span>
|
||||
<span class="tip">本地 4 方案(程序化渲染降级模式)· 点击图标下载</span>
|
||||
</div>
|
||||
|
||||
<!-- 云端 AI 结果(两个模式共用) -->
|
||||
<div v-if="aiImage" class="ai-result">
|
||||
<img :src="aiImage" class="ai-img" />
|
||||
<div class="ai-ops">
|
||||
<el-button type="primary" size="small" plain @click="downloadAi">下载 PNG</el-button>
|
||||
</div>
|
||||
<el-collapse class="prompt-box">
|
||||
<el-collapse-item title="查看优化后的提示词(由窗型数据结构化生成)">
|
||||
<div class="prompt-text">{{ aiPrompt }}</div>
|
||||
<div class="prompt-feat" v-if="aiFeatures">窗型特征:{{ aiFeatures }}</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -92,7 +108,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
|
||||
import { PHOTO_SCENES, composePhoto, type PhotoScene } from '../components/photoCompose'
|
||||
import { PHOTO_SCENES, type PhotoScene } from '../components/photoCompose'
|
||||
|
||||
const tab = ref<'photo' | 'quick'>('photo')
|
||||
const templates = ref<any[]>([])
|
||||
@@ -122,8 +138,6 @@ const stageRef = ref<HTMLCanvasElement>()
|
||||
const rect = ref<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||
let dragStart: { x: number; y: number } | null = null
|
||||
|
||||
const sceneLabel = (s: string) => PHOTO_SCENES.find(x => x.key === s)?.label ?? s
|
||||
|
||||
async function loadTemplate(tid: number | undefined) {
|
||||
drawing.value = ''
|
||||
variants.value = []
|
||||
@@ -209,52 +223,65 @@ function drawStage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 照片合成生成(4 方案: 当前场景 + 3 变化) ----
|
||||
async function generate() {
|
||||
if (!photo.value || !drawing.value || !rect.value) return
|
||||
// ---- 云端 AI 生成(gpt-image-2.5, 提示词由后端从窗型数据结构化构建) ----
|
||||
const aiImage = ref('')
|
||||
const aiPrompt = ref('')
|
||||
const aiFeatures = ref('')
|
||||
|
||||
async function generateCloud(mode: 'quick' | 'photo') {
|
||||
if (!drawing.value) return
|
||||
if (mode === 'photo' && (!photo.value || !rect.value)) return
|
||||
generating.value = true
|
||||
aiImage.value = ''
|
||||
variants.value = []
|
||||
const plan: { key: PhotoScene; label: string }[] = [
|
||||
{ key: scene.value, label: `${sceneLabel(scene.value)}` },
|
||||
{ key: 'install', label: '安装合成' },
|
||||
{ key: 'erase', label: '门窗擦除' },
|
||||
{ key: scene.value === 'render' ? 'indoor' : 'render', label: scene.value === 'render' ? '室内装修' : '渲图' },
|
||||
]
|
||||
progress.value = 8
|
||||
stageText.value = mode === 'photo' ? '上传照片与窗型特征' : '解析窗型结构'
|
||||
const timer = window.setInterval(() => {
|
||||
progress.value = Math.min(92, progress.value + 2)
|
||||
if (progress.value > 40) stageText.value = 'AI 绘制中(约 30~90 秒, 请勿关闭页面)'
|
||||
}, 1500)
|
||||
try {
|
||||
for (let s = 0; s < STAGES.length; s++) {
|
||||
stageText.value = STAGES[s]
|
||||
for (let p = 0; p <= 25; p += 5) {
|
||||
progress.value = Math.min(100, s * 25 + p)
|
||||
await new Promise(r => setTimeout(r, 18))
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
let photo_b64: string | undefined
|
||||
if (mode === 'photo') {
|
||||
photo_b64 = photoB64()
|
||||
}
|
||||
const d = await api<any>('post', '/api/ai-render/generate', {
|
||||
template_id: templateId.value, mode,
|
||||
style: form.ai_style, time: form.ai_time,
|
||||
scene: mode === 'photo' ? scene.value : form.scene, photo_b64,
|
||||
}, { timeout: 200000 })
|
||||
aiImage.value = `data:image/png;base64,${d.image_b64}`
|
||||
aiPrompt.value = d.prompt
|
||||
aiFeatures.value = d.features?.summary ?? ''
|
||||
progress.value = 100
|
||||
variants.value = plan.map((p, i) => ({
|
||||
label: p.label, style: String(p.key), time: String(i), seed: 7 + i * 13,
|
||||
}))
|
||||
pickIdx.value = 0
|
||||
// 等 Vue 挂载画廊画布后再合成输出
|
||||
await nextFrames()
|
||||
// 渲染画廊: composePhoto 输出到各画布
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const cv = variantCvs[i]
|
||||
if (!cv) continue
|
||||
const out = composePhoto(photo.value, rect.value, drawing.value, plan[i].key, 1280, 7 + i * 13)
|
||||
cv.width = out.width
|
||||
cv.height = out.height
|
||||
cv.getContext('2d')!.drawImage(out, 0, 0)
|
||||
stageText.value = `合成方案 ${i + 1}/4`
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
}
|
||||
ElMessage.success('已生成 4 套炫图方案')
|
||||
} finally {
|
||||
ElMessage.success('AI 炫图已生成')
|
||||
} catch { /* 拦截器已提示 */ } finally {
|
||||
window.clearInterval(timer)
|
||||
generating.value = false
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 风格速览(原有程序化模式) ----
|
||||
/** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */
|
||||
function photoB64(): string {
|
||||
const img = photo.value!
|
||||
const scale = Math.min(1, 1536 / img.naturalWidth)
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = Math.round(img.naturalWidth * scale)
|
||||
cv.height = Math.round(img.naturalHeight * scale)
|
||||
cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height)
|
||||
return cv.toDataURL('image/jpeg', 0.9).split(',')[1]
|
||||
}
|
||||
|
||||
function downloadAi() {
|
||||
if (!aiImage.value) return
|
||||
const a = document.createElement('a')
|
||||
a.href = aiImage.value
|
||||
a.download = 'AI炫图.png'
|
||||
a.click()
|
||||
}
|
||||
|
||||
// ---- 风格速览(本地程序化 4 方案, 未配置云端 Key 时的降级模式) ----
|
||||
async function generateQuick() {
|
||||
generating.value = true
|
||||
variants.value = []
|
||||
@@ -321,6 +348,13 @@ onMounted(async () => {
|
||||
.preview { height: 58vh; }
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||
.gallery-foot { margin-top: 8px; }
|
||||
.ai-result { margin-top: 14px; text-align: center; }
|
||||
.ai-img { max-width: 100%; max-height: 72vh; border-radius: 10px; box-shadow: 0 2px 14px rgba(0,0,0,.14); }
|
||||
.ai-ops { margin-top: 8px; }
|
||||
.prompt-box { margin-top: 10px; text-align: left; }
|
||||
.prompt-text { font-size: 12px; color: #606266; line-height: 1.7; font-family: Consolas, monospace;
|
||||
background: #f7f8fa; padding: 10px 12px; border-radius: 6px; word-break: break-word; }
|
||||
.prompt-feat { margin-top: 8px; font-size: 12px; color: #909399; }
|
||||
.shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer;
|
||||
height: 200px; transition: border-color .2s, transform .2s; }
|
||||
.shot:hover { transform: translateY(-2px); }
|
||||
|
||||
Reference in New Issue
Block a user