From 710b3521a26cb2b28a7dcd2048fab3e7ef3366c4 Mon Sep 17 00:00:00 2001 From: HHY Date: Fri, 25 Sep 2026 03:49:49 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20P0-8=20=E7=85=A7=E7=89=87=E5=BC=8FAI?= =?UTF-8?q?=E7=82=AB=E5=9B=BE(7=E5=9C=BA=E6=99=AF/=E7=BA=AF=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=90=88=E6=88=90/=E5=85=89=E7=85=A7=E5=8C=B9?= =?UTF-8?q?=E9=85=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG | 2 + doc/API.md | 10 + frontend/scripts/verify-photorender.mjs | 75 +++++++ frontend/src/components/photoCompose.ts | 278 +++++++++++++++++++++++ frontend/src/views/AIRenderView.vue | 281 +++++++++++++++++++----- shots/verify-photo-stage.png | Bin 0 -> 31472 bytes shots/verify-photorender-0.png | Bin 0 -> 14839 bytes shots/verify-photorender-1.png | Bin 0 -> 16228 bytes shots/verify-photorender-2.png | Bin 0 -> 16821 bytes shots/verify-photorender.png | Bin 0 -> 9618 bytes 10 files changed, 596 insertions(+), 50 deletions(-) create mode 100644 frontend/scripts/verify-photorender.mjs create mode 100644 frontend/src/components/photoCompose.ts create mode 100644 shots/verify-photo-stage.png create mode 100644 shots/verify-photorender-0.png create mode 100644 shots/verify-photorender-1.png create mode 100644 shots/verify-photorender-2.png create mode 100644 shots/verify-photorender.png diff --git a/CHANGELOG b/CHANGELOG index dcd8b0f..5146487 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 16:50] FEAT: v2.27 P0-8 照片式 AI 炫图(7 场景)。①photoCompose.ts 纯前端合成引擎: renderWindowOnly 透明背景纯窗型渲染(分割树重放/玻璃渐变/中梃投影/扇开向虚线三角/装饰格条, 复用 types/drawing 纯函数)+composePhoto 光照匹配(洞口周边亮度→窗户亮度 0.55~1.25)+7 场景后处理(渲图/安装合成投影/门窗擦除内容填充/室内暖色/别墅石基座/去围栏背景虚化/变毛坯去饱和); ②AIRenderView 升级双 Tab(照片合成/风格速览): 上传照片→拖框窗洞→选样板→生成 4 方案画廊+下载; 修复三处: variants 赋值后未等 Vue 挂载渲染空画布(nextFrames)、rect 舞台坐标与照片原始坐标双重缩放(统一原始坐标+CSS↔原始宽换算)、v-if canvas 未挂载 drawStage 静默失败致 photoScale 滞留 1(nextTick+无状态换算); ③端到端验证: 程序化测试照片→拖框→4 方案生成, 窗户准确贴入洞口+投影正确(shots/verify-photorender-*.png); vue-tsc 0 错+构建通过 (Files: frontend/src/components/photoCompose.ts, frontend/src/views/AIRenderView.vue, frontend/scripts/verify-photorender.mjs, doc/API.md) + - [2026-09-25 15:40] FEAT: v2.26 P0-7 玻璃装饰格条设计器。①types/drawing.ts 加 DecBar(decs 挂 FrameShape, ri=重放区域索引)+DEC_PRESETS 8 风格(对齐源站 decorationBar: 现代轻奢/意式简约/奶油风/中式/欧式古典/法式/地中海/田园密格); ②WindoorCanvas: 选格后 HUD"格条"按钮+设计对话框(预设缩略图/行列/条宽/6 色)+2D 渲染(区域 clip 内横竖条+描边, 应用/清除入撤销栈); ③WindoorThreed: 静态格+扇面板玻璃前表面格条体(barX/barY 实心腔); ④后端 takeoff 计 dec_bar 算料(横=区域宽×rows/竖=区域高×cols, 默认 12 元/米); ⑤端到端验证: 无头浏览器订单详情→编辑→选格→欧式古典 3×3 应用→截图渲染正确(shots/verify-decbar-2d.png); 254 套全量校验 0 失败(decs 可选字段无回归); vue-tsc 0 错+构建通过 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue, src/huamenchuang/services/quote_service.py, frontend/scripts/verify-decbar.mjs, doc/API.md) - [2026-09-25 14:50] FEAT: v2.25 P0-6 Excel 导入导出全套。①excel_service 扩展: read_rows 自动探测表头行(兼容首行标题合并格, 修复导入全跳过)/pick 中文列名取值/simple_table_workbook/statistics_workbook 双Sheet; ②客户: excel-template 模板/excel-in 批量导入(缺姓名行级报错)/excel-out 导出; ③合同 excel-out(含已收); ④库存 excel-out/excel-in(同名更新数量累加); ⑤reports/excel-statistics(订单汇总+收款统计); ⑥依赖补 python-multipart(UploadFile 必需, 缺失曾致后端启动失败); ⑦前端 apiUpload+四个页面导入导出按钮; 实测: 模板回传导入成功1条/库存导出→再导入更新1条; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/reports.py, frontend/src/api/index.ts, frontend/src/views/CustomersView.vue, frontend/src/views/WarehouseView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/ReportView.vue, doc/API.md) diff --git a/doc/API.md b/doc/API.md index 2f3d97b..b4a95d8 100644 --- a/doc/API.md +++ b/doc/API.md @@ -455,3 +455,13 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 - **报价**:`offer-price`/BOM 自动计入格条算料(横条=区域宽×rows、竖条=区域高×cols,默认 12 元/米,`prices.dec_bar` 可覆盖,kind=`dec_bar`) 风格预设:现代轻奢(1×1黑25)/意式简约(1×1灰18)/奶油风(2×2白22)/中式(2×2棕28)/欧式古典(3×3金20)/法式(2×2金16)/地中海(2×3蓝24)/田园密格(4×4白14)。 + +### 12.22 照片式 AI 炫图(v2.20 升级,P0-照片炫图) + +对齐目标站 `renderV2` 炫图 7 分类:**渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯**。 + +纯前端像素合成(`components/photoCompose.ts`,无需服务端 AI): + +- 流程:上传场景照片 → 拖拽框选窗洞(原始照片坐标)→ 选样板窗型 → `renderWindowOnly`(分割树重放渲染透明背景窗型:玻璃渐变/中梃/扇框开向/装饰格条)→ 光照匹配(采样洞口周边亮度调整窗户亮度)→ 边缘贴合 → 场景后处理(安装投影/擦除填充/暖色滤镜/石材基座/背景虚化/去饱和毛坯) +- 一键生成 4 方案画廊 + PNG 下载;原"风格速览"模式保留为第二 Tab +- 无新增后端接口(复用 `/api/windoor/templates` 与 `/api/rendering-info`) diff --git a/frontend/scripts/verify-photorender.mjs b/frontend/scripts/verify-photorender.mjs new file mode 100644 index 0000000..3ffaaef --- /dev/null +++ b/frontend/scripts/verify-photorender.mjs @@ -0,0 +1,75 @@ +// 照片式 AI 炫图端到端验证: 注入程序化测试照片 → 拖框 → 生成 → 截画廊 +// 用法: node verify-photorender.mjs +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)) +const opt = await page.$('.el-select-dropdown__item') +await opt.click() +await new Promise(r => setTimeout(r, 1200)) + +// 2) 程序化生成"室内照片"并注入 el-upload +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') +if (!stage) { console.log('✗ 照片舞台未出现'); process.exit(1) } +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, 400)) + +// 4) 生成 +const genBtn = await page.evaluateHandle(() => [...document.querySelectorAll('button')].find(x => x.textContent.includes('生成炫图'))) +await genBtn.asElement().click() +await new Promise(r => setTimeout(r, 6000)) + +const shots = await page.$$('.shot') +console.log(`方案数: ${shots.length}`) +if (!shots.length) { console.log('✗ 未生成方案'); process.exit(1) } +await page.evaluate(() => document.querySelector('.gallery')?.scrollIntoView({ block: 'center' })) +await new Promise(r => setTimeout(r, 500)) +if (shots[0]) await shots[0].screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photorender-0.png' }) +if (shots[1]) await shots[1].screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photorender-1.png' }) +if (shots[2]) await shots[2].screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photorender-2.png' }) +const stageEl = await page.$('.photo-cv') +if (stageEl) await stageEl.screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photo-stage.png' }) +console.log('✓ 照片合成炫图验证完成') +await browser.close() diff --git a/frontend/src/components/photoCompose.ts b/frontend/src/components/photoCompose.ts new file mode 100644 index 0000000..2415c03 --- /dev/null +++ b/frontend/src/components/photoCompose.ts @@ -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 +} diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue index 435e224..9ad3057 100644 --- a/frontend/src/views/AIRenderView.vue +++ b/frontend/src/views/AIRenderView.vue @@ -3,54 +3,98 @@ -
- - - - - - - - - - - - - - - {{ generating ? '生成中…' : '✦ 生成 AI 炫图' }} -
- - - -
- -
- -