feat: P0-8 照片式AI炫图(7场景/纯前端合成/光照匹配)

This commit is contained in:
HHY
2026-09-25 03:49:49 -07:00
parent 63f498655f
commit 710b3521a2
10 changed files with 596 additions and 50 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* 照片式 AI 炫图合成引擎(纯前端像素合成,无需服务端 AI)
*
* 流程: 用户照片 + 标注窗洞矩形 + 样板窗型渲染 → 光照匹配合成 + 场景后处理
* 场景语义对齐源站 renderV2 炫图 7 分类: 渲图/安装合成/门窗擦除/室内装修/别墅/去围栏/变毛坯
*/
import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape,
type DecBar, type DrawingData, type FrameShape,
} from '../types/drawing'
export type PhotoScene = 'render' | 'install' | 'erase' | 'indoor' | 'villa' | 'fence' | 'blank'
export const PHOTO_SCENES: { key: PhotoScene; label: string; desc: string }[] = [
{ key: 'render', label: '渲图', desc: '窗户合成进照片,光照自动匹配' },
{ key: 'install', label: '安装合成', desc: '合成+墙面投影,接近实装效果' },
{ key: 'erase', label: '门窗擦除', desc: '擦除洞口原窗户后装入新窗' },
{ key: 'indoor', label: '室内装修', desc: '暖色家居氛围滤镜' },
{ key: 'villa', label: '别墅', desc: '石材外墙质感场景' },
{ key: 'fence', label: '去围栏', desc: '背景虚化聚焦门窗' },
{ key: 'blank', label: '变毛坯', desc: '水泥毛坯房效果' },
]
/**
* 纯窗户渲染(透明背景, 供照片合成): 分割树重放 → 玻璃渐变/格条/扇框开向/外框窗套
*/
export function renderWindowOnly(w: number, h: number, drawingRaw: string | object): HTMLCanvasElement {
const cv = document.createElement('canvas')
cv.width = Math.max(64, w)
cv.height = Math.max(48, h)
const ctx = cv.getContext('2d')!
let dd: DrawingData
try { dd = parseDrawing(drawingRaw) } catch { return cv }
if (!dd?.sm?.length) return cv
const pts = dd.sm.flatMap(s => replayShape(s).regions.flat().flatMap(seg => [seg.ps, seg.pe]))
if (!pts.length) return cv
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const pad = Math.max(10, cv.width * 0.03)
const scale = Math.min((cv.width - pad * 2) / gw, (cv.height - pad * 2) / gh)
const ox = (cv.width - gw * scale) / 2, oy = (cv.height - gh * scale) / 2
const tx = (x: number) => ox + (x - gb.x0) * scale
const ty = (y: number) => oy + (y - gb.y0) * scale
const drawShape = (shape: FrameShape) => {
const rp = replayShape(shape)
const regions = rp.regions.map(regionPoints).filter(p => p.length >= 3)
// 玻璃: 蓝绿渐变 + 斜向高光
for (const rpts of regions) {
const bb = bboxOf(rpts)
const gx = tx(bb.x0), gy = ty(bb.y0), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale
const grad = ctx.createLinearGradient(gx, gy, gx + gw2, gy + gh2)
grad.addColorStop(0, '#bfe0ea')
grad.addColorStop(0.45, '#9ec9d8')
grad.addColorStop(0.6, '#8fbccb')
grad.addColorStop(1, '#c8e2ec')
ctx.beginPath()
rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = grad
ctx.fill()
ctx.save()
ctx.clip()
ctx.globalAlpha = 0.2
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.moveTo(gx + gw2 * 0.08, gy)
ctx.lineTo(gx + gw2 * 0.34, gy)
ctx.lineTo(gx + gw2 * 0.08, gy + gh2)
ctx.lineTo(gx - gw2 * 0.06, gy + gh2)
ctx.closePath()
ctx.fill()
ctx.restore()
ctx.strokeStyle = 'rgba(70,76,84,0.9)'
ctx.lineWidth = 2
ctx.stroke()
}
// 中梃: sp 在其作用区域 bbox 内的投影线
const regionBBs = regions.map(bboxOf)
for (const sp of (shape.mm?.sp ?? [])) {
const line = sp.line ?? {}
const n = line.norm ?? {}
const pt = line.pt ?? {}
if (typeof pt.x !== 'number' || typeof pt.y !== 'number') continue
const axis = Math.abs(n.x ?? 0) >= Math.abs(n.y ?? 0) ? 'v' : 'h'
const pos = axis === 'v' ? pt.x : pt.y
let best: { x0: number; y0: number; x1: number; y1: number } | null = null
for (const bb of regionBBs) {
const lo = axis === 'v' ? bb.x0 : bb.y0
const hi = axis === 'v' ? bb.x1 : bb.y1
if (lo - 1 <= pos && pos <= hi + 1) best = bb
}
if (!best) continue
ctx.strokeStyle = '#565b62'
ctx.lineWidth = Math.max(3, 50 * scale * 0.12)
ctx.beginPath()
if (axis === 'v') { ctx.moveTo(tx(pos), ty(best.y0)); ctx.lineTo(tx(pos), ty(best.y1)) }
else { ctx.moveTo(tx(best.x0), ty(pos)); ctx.lineTo(tx(best.x1), ty(pos)) }
ctx.stroke()
}
// 扇: 扇框 + 开向三角
const glyphs = inferSashGlyphs(shape, rp).sashes
for (const g of glyphs) {
const bx0 = tx(g.x0), by0 = ty(g.y0), bx1 = tx(g.x1), by1 = ty(g.y1)
if (bx1 - bx0 < 8 || by1 - by0 < 8) continue
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = Math.max(2.5, 60 * scale * 0.1)
ctx.strokeRect(bx0, by0, bx1 - bx0, by1 - by0)
// 开向示意(虚线三角, 指向铰链对侧)
ctx.save()
ctx.setLineDash([6, 5])
ctx.strokeStyle = 'rgba(255,255,255,0.75)'
ctx.lineWidth = 1.6
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
const dir = (g.openDirection ?? 'left') as string
ctx.beginPath()
if (dir.startsWith('up')) { ctx.moveTo(bx0 + 4, by1 - 4); ctx.lineTo(bx1 - 4, by1 - 4); ctx.lineTo(cx, by0 + 6) }
else if (dir.includes('right')) { ctx.moveTo(bx0 + 4, by0 + 4); ctx.lineTo(bx0 + 4, by1 - 4); ctx.lineTo(bx1 - 6, cy) }
else { ctx.moveTo(bx1 - 4, by0 + 4); ctx.lineTo(bx1 - 4, by1 - 4); ctx.lineTo(bx0 + 6, cy) }
ctx.closePath()
ctx.stroke()
ctx.restore()
}
// 装饰格条
for (const dec of (shape.decs ?? []) as DecBar[]) {
const segs = rp.regions[dec.ri]
if (!segs) continue
const rpts = regionPoints(segs)
if (rpts.length < 3) continue
const bb = bboxOf(rpts)
const gx = tx(bb.x0), gy = ty(bb.y0), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale
const barPx = Math.max(1.5, dec.bw * scale)
ctx.save()
ctx.beginPath(); ctx.rect(gx, gy, gw2, gh2); ctx.clip()
ctx.fillStyle = dec.color
ctx.globalAlpha = 0.92
for (let i = 1; i <= dec.rows; i++) ctx.fillRect(gx, gy + (gh2 * i) / (dec.rows + 1) - barPx / 2, gw2, barPx)
for (let i = 1; i <= dec.cols; i++) ctx.fillRect(gx + (gw2 * i) / (dec.cols + 1) - barPx / 2, gy, barPx, gh2)
ctx.restore()
}
}
// 窗套(白色外框) + 各形状
ctx.fillStyle = '#f6f5f2'
ctx.fillRect(tx(gb.x0) - pad * 0.6, ty(gb.y0) - pad * 0.6, gw * scale + pad * 1.2, gh * scale + pad * 1.2)
ctx.strokeStyle = '#d9d6cf'
ctx.lineWidth = 2
ctx.strokeRect(tx(gb.x0) - pad * 0.6, ty(gb.y0) - pad * 0.6, gw * scale + pad * 1.2, gh * scale + pad * 1.2)
for (const shape of dd.sm) drawShape(shape)
// 整体外框
ctx.strokeStyle = '#43474c'
ctx.lineWidth = 6
ctx.strokeRect(tx(gb.x0) - 3, ty(gb.y0) - 3, gw * scale + 6, gh * scale + 6)
return cv
}
function avgLuma(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): number {
const d = ctx.getImageData(Math.max(0, x | 0), Math.max(0, y | 0), Math.max(1, w | 0), Math.max(1, h | 0)).data
let s = 0
const n = Math.min(d.length, 4 * 4000)
for (let i = 0; i < n; i += 4) s += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]
return s / (n / 4) / 255
}
/** 把窗户合成进照片矩形: 保持窗型宽高比居中 + 光照匹配 + 边缘羽化 */
function blendWindow(out: CanvasRenderingContext2D, win: HTMLCanvasElement,
rect: { x: number; y: number; w: number; h: number }, photoLuma: number) {
// 窗户保持宽高比放进矩形(居中)
const scale = Math.min(rect.w / win.width, rect.h / win.height)
const w = win.width * scale, h = win.height * scale
const x = rect.x + (rect.w - w) / 2, y = rect.y + (rect.h - h) / 2
// 光照匹配: 照片偏暗则压暗窗户
const bright = Math.max(0.55, Math.min(1.25, 0.35 + photoLuma))
out.save()
out.filter = `brightness(${bright.toFixed(2)})`
out.drawImage(win, x, y, w, h)
out.restore()
}
/** 场景后处理 */
function applyScene(ctx: CanvasRenderingContext2D, scene: PhotoScene,
rect: { x: number; y: number; w: number; h: number }) {
const { x, y, w, h } = rect
if (scene === 'install') {
// 墙面投影: 矩形右下侧柔和阴影
ctx.save()
const g = ctx.createLinearGradient(x + w, y, x + w + w * 0.12, y + h)
g.addColorStop(0, 'rgba(0,0,0,0.28)')
g.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = g
ctx.fillRect(x + w, y, w * 0.12, h)
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.fillRect(x, y + h, w, h * 0.05)
ctx.restore()
} else if (scene === 'indoor') {
ctx.save()
ctx.globalCompositeOperation = 'overlay'
ctx.fillStyle = 'rgba(255,190,120,0.16)'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
} else if (scene === 'villa') {
// 石材基座: 底部 12% 灰色石带 + 分缝线
ctx.save()
const base = ctx.canvas.height * 0.88
ctx.fillStyle = 'rgba(160,158,150,0.55)'
ctx.fillRect(0, base, ctx.canvas.width, ctx.canvas.height - base)
ctx.strokeStyle = 'rgba(90,88,82,0.4)'
ctx.lineWidth = 1.5
for (let gx = 0; gx < ctx.canvas.width; gx += 90) {
ctx.beginPath(); ctx.moveTo(gx, base); ctx.lineTo(gx, ctx.canvas.height); ctx.stroke()
}
ctx.restore()
} else if (scene === 'fence') {
// 背景虚化: 矩形外区域模糊叠加
const cv = ctx.canvas
const snap = document.createElement('canvas')
snap.width = cv.width; snap.height = cv.height
snap.getContext('2d')!.drawImage(cv, 0, 0)
ctx.save()
ctx.filter = 'blur(5px)'
ctx.drawImage(snap, 0, 0)
ctx.restore()
ctx.save()
ctx.filter = 'none'
ctx.clearRect(x, y, w, h)
ctx.drawImage(snap, x, y, w, h, x, y, w, h)
ctx.restore()
} else if (scene === 'blank') {
// 变毛坯: 全图去饱和 + 水泥灰色调
ctx.save()
ctx.globalCompositeOperation = 'saturation'
ctx.fillStyle = 'hsl(0,12%,50%)'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = 'soft-light'
ctx.fillStyle = 'rgba(140,138,132,0.5)'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
}
}
/**
* 主合成入口: 照片 + 窗洞矩形 + 窗户渲染图 + 场景 → 合成结果画布
*/
export function composePhoto(photo: HTMLImageElement, rect: { x: number; y: number; w: number; h: number },
drawing: string | object, scene: PhotoScene,
outW = 1280, seed = 7): HTMLCanvasElement {
// 统一输出尺寸: 照片等比缩放到 outW
const scale = outW / photo.naturalWidth
const outH = Math.round(photo.naturalHeight * scale)
const cv = document.createElement('canvas')
cv.width = outW
cv.height = outH
const ctx = cv.getContext('2d')!
ctx.drawImage(photo, 0, 0, outW, outH)
const r = { x: rect.x * scale, y: rect.y * scale, w: rect.w * scale, h: rect.h * scale }
// 擦除模式: 洞口先用周边平均色+模糊填充(近似内容擦除)
if (scene === 'erase') {
const pad = 24
const lumaCtx = ctx
const sample = avgLuma(lumaCtx, r.x - pad, r.y - pad, r.w + pad * 2, r.h + pad * 2)
ctx.save()
ctx.filter = 'blur(14px)'
ctx.fillStyle = `hsl(0,0%,${Math.round(sample * 100)}%)`
ctx.fillRect(r.x, r.y, r.w, r.h)
ctx.restore()
}
// 渲染窗户并合成(透明背景纯窗型, 贴合洞口)
const win = renderWindowOnly(Math.max(320, Math.round(r.w)), Math.max(240, Math.round(r.h)), drawing)
const luma = avgLuma(ctx, Math.max(0, r.x - 30), Math.max(0, r.y - 30), r.w + 60, r.h + 60)
blendWindow(ctx, win, r, luma)
applyScene(ctx, scene, r)
return cv
}
+231 -50
View File
@@ -3,54 +3,98 @@
<template #header>
<div class="head">
<span class="brand">✦ AI 炫图</span>
<span class="sub">选择样板 → 设定风格 → 一键生成多方案效果图</span>
<el-radio-group v-model="tab" size="small">
<el-radio-button value="photo">照片合成</el-radio-button>
<el-radio-button value="quick">风格速览</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
<el-select v-model="form.ai_style" style="width:130px">
<el-option value="modern" label="现代简约" /><el-option value="chinese" label="新中式" />
<el-option value="european" label="欧式" /><el-option value="nordic" label="北欧" />
</el-select>
<el-select v-model="form.ai_time" style="width:110px">
<el-option value="day" label="日景" /><el-option value="dusk" label="黄昏" /><el-option value="night" label="夜景" />
</el-select>
<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="generate">
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
:format="() => stageText" />
<div v-if="drawing && !variants.length && !generating" class="preview">
<AIRenderCanvas :drawing-data="drawing" :style="form.ai_style" :time="form.ai_time"
:scene="form.scene" :seed="form.seed" />
</div>
<div v-if="variants.length" class="gallery">
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
<div class="cap">{{ v.label }}</div>
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
<!-- ============ 照片合成(对齐源站 renderV2 炫图 7 场景) ============ -->
<template v-if="tab === 'photo'">
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板窗型" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
<el-select v-model="scene" style="width:150px">
<el-option v-for="s in PHOTO_SCENES" :key="s.key" :value="s.key" :label="s.label" />
</el-select>
<span class="tip">{{ sceneDesc }}</span>
<div style="flex:1" />
<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>
</div>
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
<el-icon size="40"><UploadFilled /></el-icon>
<p>上传室内/室外场景照片,在照片上<b>拖拽框选窗洞位置</b>,选择样板窗型,一键合成真实炫图</p>
<p class="sub">支持 渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯 七种场景</p>
</div>
</div>
<el-empty v-if="!drawing" description="请先选择样板" />
<div v-if="photo" class="photo-stage">
<canvas ref="stageRef" class="photo-cv" @mousedown="onDown" @mousemove="onMove" @mouseup="onUp"
@mouseleave="onUp" />
<div class="stage-tip">在照片上拖拽框选窗洞位置(松开生效,可重新框选)</div>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
:format="() => stageText" />
<div v-if="variants.length" class="gallery">
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
<div class="cap">{{ v.label }}</div>
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
</div>
</div>
</template>
<!-- ============ 风格速览(原程序化模式) ============ -->
<template v-else>
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
<el-select v-model="form.ai_style" style="width:130px">
<el-option value="modern" label="现代简约" /><el-option value="chinese" label="新中式" />
<el-option value="european" label="欧式" /><el-option value="nordic" label="北欧" />
</el-select>
<el-select v-model="form.ai_time" style="width:110px">
<el-option value="day" label="日景" /><el-option value="dusk" label="黄昏" /><el-option value="night" label="夜景" />
</el-select>
<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>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
:format="() => stageText" />
<div v-if="drawing && !variants.length && !generating" class="preview">
<AIRenderCanvas :drawing-data="drawing" :style="form.ai_style" :time="form.ai_time"
:scene="form.scene" :seed="form.seed" />
</div>
<el-empty v-if="!drawing" description="请先选择样板" />
</template>
<div v-if="variants.length" class="gallery-foot">
<span class="tip">点击方案切换大图预览,图标下载 PNG</span>
</div>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
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'
const tab = ref<'photo' | 'quick'>('photo')
const templates = ref<any[]>([])
const templateId = ref<number | undefined>()
const drawing = ref('')
@@ -71,6 +115,15 @@ function setVariantRef(el: any, i: number) { variantCvs[i] = el as HTMLCanvasEle
const STAGES = ['解析窗型结构', '合成场景光照', '匹配材质纹理', '生成效果图']
const scene = ref<PhotoScene>('render')
const sceneDesc = computed(() => PHOTO_SCENES.find(s => s.key === scene.value)?.desc ?? '')
const photo = ref<HTMLImageElement | null>(null)
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 = []
@@ -79,10 +132,132 @@ async function loadTemplate(tid: number | undefined) {
drawing.value = t.drawing_data
}
// ---- 照片上传与框选 ----
function triggerUpload() { (document.querySelector('.el-upload input[type=file]') as HTMLInputElement)?.click() }
async function onUpload(opt: any) {
const file = opt.file as File
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = async () => {
photo.value = img
rect.value = null
variants.value = []
await nextTick() // v-if 的舞台 canvas 挂载后再绘制
drawStage()
URL.revokeObjectURL(url)
}
img.src = url
}
function stagePos(e: MouseEvent) {
const cv = stageRef.value!
const b = cv.getBoundingClientRect()
// CSS 像素 → 原始照片坐标(不依赖舞台缩放状态)
return { x: (e.clientX - b.left) * photo.value!.naturalWidth / b.width,
y: (e.clientY - b.top) * photo.value!.naturalHeight / b.height }
}
let photoScale = 1
function onDown(e: MouseEvent) {
if (!photo.value) return
dragStart = stagePos(e)
rect.value = null
}
function onMove(e: MouseEvent) {
if (!dragStart || !photo.value) return
const p = stagePos(e)
rect.value = {
x: Math.min(dragStart.x, p.x), y: Math.min(dragStart.y, p.y),
w: Math.abs(p.x - dragStart.x), h: Math.abs(p.y - dragStart.y),
}
drawStage()
}
function onUp(e: MouseEvent) {
if (!dragStart) return
const p = stagePos(e)
if (rect.value && (rect.value.w < 20 || rect.value.h < 20)) rect.value = null
dragStart = null
void p
drawStage()
}
/** 绘制照片舞台: 照片 + 框选矩形 + 遮罩(rect 为原始照片坐标) */
function drawStage() {
const cv = stageRef.value
if (!cv || !photo.value) return
const maxW = Math.min(920, window.innerWidth - 80)
photoScale = Math.min(1, maxW / photo.value.naturalWidth)
cv.width = Math.round(photo.value.naturalWidth * photoScale)
cv.height = Math.round(photo.value.naturalHeight * photoScale)
const ctx = cv.getContext('2d')!
ctx.drawImage(photo.value, 0, 0, cv.width, cv.height)
if (rect.value) {
const rx = rect.value.x * photoScale, ry = rect.value.y * photoScale
const rw = rect.value.w * photoScale, rh = rect.value.h * photoScale
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.35)'
ctx.fillRect(0, 0, cv.width, cv.height)
ctx.clearRect(rx, ry, rw, rh)
ctx.drawImage(photo.value, rect.value.x, rect.value.y, rect.value.w, rect.value.h, rx, ry, rw, rh)
ctx.strokeStyle = '#5b3fd4'
ctx.lineWidth = 2.5
ctx.setLineDash([8, 5])
ctx.strokeRect(rx, ry, rw, rh)
ctx.restore()
}
}
// ---- 照片合成生成(4 方案: 当前场景 + 3 变化) ----
async function generate() {
if (!photo.value || !drawing.value || !rect.value) return
generating.value = true
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' ? '室内装修' : '渲图' },
]
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))
}
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 {
generating.value = false
progress.value = 0
}
}
// ---- 风格速览(原有程序化模式) ----
async function generateQuick() {
generating.value = true
variants.value = []
// 阶段化进度(本地程序化渲染,进度为演示动画)
for (let s = 0; s < STAGES.length; s++) {
stageText.value = STAGES[s]
for (let p = 0; p <= 25; p += 5) {
@@ -91,21 +266,19 @@ async function generate() {
}
}
progress.value = 100
// 生成 4 个方案:风格×时间组合 + seed 微差
variants.value = [
{ label: `${styleName(form.ai_style)} · ${timeName(form.ai_time)}`, style: form.ai_style, time: form.ai_time, seed: form.seed },
{ label: `${styleName(form.ai_style)} · 日景`, style: form.ai_style, time: 'day', seed: form.seed + 11 },
{ label: `${styleName(form.ai_style)} · 黄昏`, style: form.ai_style, time: 'dusk', seed: form.seed + 23 },
{ label: '新中式 · 夜景', style: 'chinese', time: 'night', seed: form.seed + 37 },
{ label: `现代简约 · 日景`, style: 'modern', time: 'day', seed: form.seed },
{ label: `新中式 · 日景`, style: 'chinese', time: 'day', seed: form.seed + 11 },
{ label: `欧式 · 黄昏`, style: 'european', time: 'dusk', seed: form.seed + 23 },
{ label: '北欧 · 夜景', style: 'nordic', time: 'night', seed: form.seed + 37 },
]
pickIdx.value = 0
generating.value = false
await nextFrames()
// 渲染画廊各画布(共享渲染引擎)
const { renderAI } = await import('../components/variantRenderer')
variants.value.forEach((v, i) => {
const cv = variantCvs[i]
if (cv) renderAI(cv, drawing.value, v.style, v.time, form.scene, v.seed)
if (cv) renderAI(cv, drawing.value, v.style as any, v.time as any, form.scene, v.seed)
})
ElMessage.success('已生成 4 套效果图')
}
@@ -114,9 +287,6 @@ function nextFrames(): Promise<void> {
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
}
function styleName(s: string) { return ({ modern: '现代简约', chinese: '新中式', european: '欧式', nordic: '北欧' } as Record<string, string>)[s] ?? s }
function timeName(t: string) { return ({ day: '日景', dusk: '黄昏', night: '夜景' } as Record<string, string>)[t] ?? t }
function download(i: number) {
const cv = variantCvs[i]
if (!cv) return
@@ -134,17 +304,28 @@ onMounted(async () => {
</script>
<style scoped>
.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.brand { font-size: 17px; font-weight: 700; color: #5b3fd4; }
.sub { color: #909399; font-size: 12px; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; align-items: center; }
.tip { color: #909399; font-size: 12px; }
.drop-hint { border: 2px dashed #c8cdd6; border-radius: 12px; padding: 56px 20px; text-align: center;
color: #606266; cursor: pointer; transition: border-color .2s, background .2s; }
.drop-hint:hover { border-color: #5b3fd4; background: #faf8ff; }
.drop-hint p { margin: 10px 0 0; }
.drop-hint .sub { font-size: 12px; }
.photo-stage { position: relative; display: flex; justify-content: center; }
.photo-cv { max-width: 100%; border-radius: 10px; cursor: crosshair; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.stage-tip { position: absolute; left: 12px; top: 12px; color: #fff; font-size: 12px;
background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 10px; pointer-events: none; }
.preview { height: 58vh; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
.gallery-foot { margin-top: 8px; }
.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); }
.shot.pick { border-color: #5b3fd4; }
.shot-cv { width: 100%; height: 100%; display: block; }
.shot-cv { width: 100%; height: 100%; display: block; object-fit: cover; }
.cap { position: absolute; left: 8px; bottom: 6px; color: #fff; font-size: 12px;
background: rgba(0,0,0,0.45); padding: 2px 8px; border-radius: 10px; }
.dl { position: absolute; right: 8px; top: 8px; color: #fff; background: rgba(0,0,0,0.45);