diff --git a/app/entry/src/main/ets/core/ImageCodec.ets b/app/entry/src/main/ets/core/ImageCodec.ets new file mode 100644 index 0000000..4b0a17b --- /dev/null +++ b/app/entry/src/main/ets/core/ImageCodec.ets @@ -0,0 +1,123 @@ +/** + * ImageCodec:选图/拍照 + 压缩 + 编码(试衣间/发型设计 Phase A)。 + * + * 纪律(对应《试衣间与发型设计-产品方案》§3): + * - 上传前统一压到长边 ≤1600、JPEG quality=85——换装服务建议 1024~2048, + * 发型接口不收 WebP,统一重编码 JPEG 后不存在格式问题; + * - base64 体积约为二进制的 1.33 倍,压缩后单张 ~300KB,弱网也可接受。 + */ + +import { image } from '@kit.ImageKit' +import { util } from '@kit.ArkTS' +import { photoAccessHelper } from '@kit.MediaLibraryKit' +import { cameraPicker } from '@kit.CameraKit' +import { camera } from '@kit.CameraKit' +import { common } from '@kit.AbilityKit' +import fs from '@ohos.file.fs' +import { ContextHolder } from './ContextHolder' + +export interface PickedImage { + uri: string + width: number + height: number +} + +const LONG_EDGE: number = 1600 +const JPEG_QUALITY: number = 85 + +export class ImageCodec { + /** 相册选 1 张。用户取消返回 undefined。 */ + static async pickFromGallery(): Promise { + const picker = new photoAccessHelper.PhotoViewPicker() + const opts = new photoAccessHelper.PhotoSelectOptions() + opts.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE + opts.maxSelectNumber = 1 + const res = await picker.select(opts) + if (res.photoUris.length === 0) { + return undefined + } + return ImageCodec.describe(res.photoUris[0]) + } + + /** 调相机拍 1 张。用户取消返回 undefined。 */ + static async takePhoto(): Promise { + const ctx = ContextHolder.get() as common.UIAbilityContext + const profile: cameraPicker.PickerProfile = { cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK } + const res = await cameraPicker.pick(ctx, [cameraPicker.PickerMediaType.PHOTO], profile) + if (res.resultUri.length === 0) { + return undefined + } + return ImageCodec.describe(res.resultUri) + } + + /** 读取图片尺寸(不解码全图)。 */ + static async describe(uri: string): Promise { + const file = fs.openSync(uri, fs.OpenMode.READ_ONLY) + try { + const src = image.createImageSource(file.fd) + const info = await src.getImageInfo() + await src.release() + return { uri: uri, width: info.size.width, height: info.size.height } + } finally { + fs.closeSync(file) + } + } + + /** 压缩 → JPEG ArrayBuffer(multipart 上传用)。 */ + static async toJpegBuffer(uri: string): Promise { + const file = fs.openSync(uri, fs.OpenMode.READ_ONLY) + try { + const src = image.createImageSource(file.fd) + const info = await src.getImageInfo() + const longSide: number = Math.max(info.size.width, info.size.height) + const scale: number = longSide > LONG_EDGE ? LONG_EDGE / longSide : 1 + const decodeOpts: image.DecodingOptions = { + desiredSize: { + width: Math.max(1, Math.floor(info.size.width * scale)), + height: Math.max(1, Math.floor(info.size.height * scale)) + } + } + const pm = await src.createPixelMap(decodeOpts) + const packer = image.createImagePacker() + const buf = await packer.packToData(pm, { format: 'image/jpeg', quality: JPEG_QUALITY }) + pm.release() + await src.release() + return buf + } finally { + fs.closeSync(file) + } + } + + /** 压缩 → Base64 字符串(换装 JSON 接口用,不带 data: 前缀)。 */ + static async toJpegBase64(uri: string): Promise { + const buf = await ImageCodec.toJpegBuffer(uri) + const helper = new util.Base64Helper() + return helper.encodeToStringSync(new Uint8Array(buf)) + } + + /** Base64(可含 data: 前缀)→ 解码为 PixelMap(结果图展示用)。 */ + static async base64ToPixelMap(base64: string): Promise { + const pure: string = base64.includes(',') ? base64.substring(base64.indexOf(',') + 1) : base64 + const helper = new util.Base64Helper() + const bytes = helper.decodeSync(pure) + const src = image.createImageSource(bytes.buffer as ArrayBuffer) + const pm = await src.createPixelMap() + await src.release() + return pm + } + + /** Base64(可含 data: 前缀)→ 写入缓存文件,返回路径(保存相册用)。 */ + static base64ToCacheFile(base64: string, fileName: string): string { + const pure: string = base64.includes(',') ? base64.substring(base64.indexOf(',') + 1) : base64 + const helper = new util.Base64Helper() + const bytes = helper.decodeSync(pure) + const path: string = `${ContextHolder.get().cacheDir}/${fileName}` + const file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC) + try { + fs.writeSync(file.fd, bytes.buffer as ArrayBuffer) + } finally { + fs.closeSync(file) + } + return path + } +} diff --git a/app/entry/src/main/ets/core/TryonApi.ets b/app/entry/src/main/ets/core/TryonApi.ets new file mode 100644 index 0000000..c124df2 --- /dev/null +++ b/app/entry/src/main/ets/core/TryonApi.ets @@ -0,0 +1,164 @@ +/** + * TryonApi:自有换装/发型线上服务直连封装(Phase A 内测版,详见产品方案 §1.4)。 + * + * 服务形态(2026-08-29 实测): + * - 换装 POST /change_cloth_base64:JSON + Base64 图,同步返回 OSS URL,耗时数十秒~几分钟; + * - 发型 POST /hair/api/change-hair|change-hair-color:multipart,返回内嵌 Base64 Data URL,5~10s; + * - 均无鉴权(内测期直连;正式版必须切后端代理,届时本类整体替换实现、接口签名不变)。 + * + * 错误一律抛 ApiError(与 ApiClient 约定一致,UI 层只 catch ApiError)。 + */ + +import http from '@ohos.net.http' +import { ApiError } from './ApiTypes' + +export interface Hairstyle { + id: string + name: string + image_url: string // 同源相对路径,如 /hair/images/hairstyles/.jpg +} + +interface HairstyleListData { + hairstyles: Hairstyle[] +} + +interface HairstyleListWire { + success: boolean + data: HairstyleListData +} + +interface HairResultData { + result_image: string // Base64 Data URL +} + +interface HairResultWire { + success: boolean + message?: string + data?: HairResultData +} + +interface ChangeClothWire { + ret: number + state: number + msg: string + data?: string // OSS 签名 URL +} + +const BASE: string = 'https://meida.xiangsilian.com' +const HAIR_TIMEOUT_MS: number = 120000 +const CLOTH_TIMEOUT_MS: number = 320000 // 服务端最长 ~300s,客户端必须更长(产品方案 §2.1.1) + +export class TryonApi { + /** 发型列表(60 款)。 */ + static async listHairstyles(): Promise { + const client = http.createHttp() + try { + const resp = await client.request(`${BASE}/hair/api/hairstyles`, { + method: http.RequestMethod.GET, + connectTimeout: 10000, + readTimeout: 30000 + }) + const wire = JSON.parse(resp.result as string) as HairstyleListWire + if (!wire.success) { + throw new ApiError(50000, '发型列表加载失败', '请稍后重试') + } + return wire.data.hairstyles + } catch (e) { + throw TryonApi.wrap(e, '发型列表加载失败') + } finally { + client.destroy() + } + } + + /** 发型缩略图完整 URL。 */ + static hairstyleImageUrl(style: Hairstyle): string { + return style.image_url.startsWith('http') ? style.image_url : `${BASE}${style.image_url}` + } + + /** + * 换发型。imageBuf 为已压缩 JPEG(ImageCodec.toJpegBuffer)。 + * 返回结果图的 Base64 Data URL。失败抛 ApiError(message 用服务端原文)。 + */ + static async changeHair(imageBuf: ArrayBuffer, hairstyleId: string): Promise { + const form: http.MultiFormData[] = [ + { name: 'image', contentType: 'image/jpeg', remoteFileName: 'photo.jpg', data: imageBuf }, + { name: 'hairstyle_id', contentType: 'text/plain', data: hairstyleId } + ] + return TryonApi.postHairForm(`${BASE}/hair/api/change-hair`, form) + } + + /** 换发色。rgb 为字符串化数组("[255, 0, 0]"——服务端要的怪格式,不是 hex)。 */ + static async changeHairColor(imageBuf: ArrayBuffer, rgb: string, ratio: number): Promise { + const form: http.MultiFormData[] = [ + { name: 'image', contentType: 'image/jpeg', remoteFileName: 'photo.jpg', data: imageBuf }, + { name: 'rgb', contentType: 'text/plain', data: rgb }, + { name: 'ratio', contentType: 'text/plain', data: `${ratio}` } + ] + return TryonApi.postHairForm(`${BASE}/hair/api/change-hair-color`, form) + } + + /** + * 换装(三工作流按参数自动路由):humanBase64 必填; + * clothBase64 有=上衣换装;kuziBase64 也有=上衣+裤子(只传裤子服务端会解码失败,UI 层已挡)。 + * 返回结果图 OSS 签名 URL。 + */ + static async changeCloth(humanBase64: string, clothBase64: string, kuziBase64: string, + changeDesc: string): Promise { + const client = http.createHttp() + try { + const body: Record = { + 'human_img': humanBase64, + 'cloth_img': clothBase64, + 'kuzi_img': kuziBase64, + 'change_desc': changeDesc + } + const resp = await client.request(`${BASE}/change_cloth_base64`, { + method: http.RequestMethod.POST, + header: { 'Content-Type': 'application/json' }, + extraData: JSON.stringify(body), + expectDataType: http.HttpDataType.STRING, + connectTimeout: 15000, + readTimeout: CLOTH_TIMEOUT_MS + }) + const wire = JSON.parse(resp.result as string) as ChangeClothWire + if (wire.ret === 0 && wire.data !== undefined && wire.data.length > 0) { + return wire.data + } + throw new ApiError(50000, wire.msg || '换装生成失败', '请换张照片重试') + } catch (e) { + throw TryonApi.wrap(e, '换装请求失败') + } finally { + client.destroy() + } + } + + private static async postHairForm(url: string, form: http.MultiFormData[]): Promise { + const client = http.createHttp() + try { + const resp = await client.request(url, { + method: http.RequestMethod.POST, + multiFormDataList: form, + expectDataType: http.HttpDataType.STRING, + connectTimeout: 10000, + readTimeout: HAIR_TIMEOUT_MS + }) + const wire = JSON.parse(resp.result as string) as HairResultWire + if (wire.success && wire.data !== undefined && wire.data.result_image.length > 0) { + return wire.data.result_image + } + throw new ApiError(50000, wire.message ?? '生成失败', '请换张照片重试') + } catch (e) { + throw TryonApi.wrap(e, '请求失败,请检查网络') + } finally { + client.destroy() + } + } + + /** 与 ApiClient 同一纪律:未知异常包装成 ApiError。 */ + private static wrap(e: Object, fallback: string): ApiError { + if (e instanceof ApiError) { + return e + } + return new ApiError(50000, fallback, '请稍后重试') + } +} diff --git a/app/entry/src/main/ets/pages/HairDesignPage.ets b/app/entry/src/main/ets/pages/HairDesignPage.ets new file mode 100644 index 0000000..1239067 --- /dev/null +++ b/app/entry/src/main/ets/pages/HairDesignPage.ets @@ -0,0 +1,304 @@ +/** + * HairDesignPage —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。 + * + * 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。 + * 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。 + */ + +import { router } from '@kit.ArkUI' +import { image } from '@kit.ImageKit' +import { util } from '@kit.ArkTS' +import { photoAccessHelper } from '@kit.MediaLibraryKit' +import { fileUri } from '@kit.CoreFileKit' +import { TryonApi, Hairstyle } from '../core/TryonApi' +import { ImageCodec } from '../core/ImageCodec' +import { ApiError } from '../core/ApiTypes' +import { ContextHolder } from '../core/ContextHolder' + +interface PresetColor { + name: string + rgb: number[] +} + +const PRESET_COLORS: PresetColor[] = [ + { name: '自然黑', rgb: [30, 30, 30] }, + { name: '深棕', rgb: [74, 48, 36] }, + { name: '栗色', rgb: [128, 70, 42] }, + { name: '蜜茶', rgb: [156, 108, 66] }, + { name: '亚麻', rgb: [150, 140, 130] }, + { name: '酒红', rgb: [128, 32, 48] }, + { name: '奶奶灰', rgb: [160, 160, 165] }, + { name: '蓝黑', rgb: [40, 50, 70] } +] + +const PAGE_BG: string = '#F5F6F8' +const ACCENT: string = '#FF5A79' +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' + +@Entry +@Component +struct HairDesignPage { + @State photoUri: string = '' + @State styles: Hairstyle[] = [] + @State listError: string = '' + @State selectedStyleId: string = '' + @State generating: boolean = false + @State statusText: string = '' + @State resultPm: image.PixelMap | undefined = undefined + @State resultBase64: string = '' // 换发色/保存都用它,避免二次编码 + @State showOriginal: boolean = false + @State colorIdx: number = 1 + @State ratio: number = 0.9 + + aboutToAppear(): void { + this.loadStyles() + // 预留:Agent 联动(V2)预选发型,见产品方案 §7 + const params = router.getParams() as Record | undefined + if (params !== undefined && typeof params['hairstyle_id'] === 'string') { + this.selectedStyleId = params['hairstyle_id'] as string + } + } + + private async loadStyles(): Promise { + try { + this.styles = await TryonApi.listHairstyles() + } catch (e) { + this.listError = e instanceof ApiError ? e.message : '发型列表加载失败' + } + } + + private async pickPhoto(fromCamera: boolean): Promise { + try { + const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() + if (picked === undefined) { + return + } + this.photoUri = picked.uri + this.resultPm = undefined + this.resultBase64 = '' + this.selectedStyleId = '' + this.statusText = '' + } catch (e) { + this.statusText = e instanceof ApiError ? e.message : '读取照片失败' + } + } + + private async tryOn(style: Hairstyle): Promise { + if (this.photoUri.length === 0 || this.generating) { + return + } + this.generating = true + this.selectedStyleId = style.id + this.showOriginal = false + this.statusText = `正在试戴「${style.name}」,约 5~15 秒…` + try { + const buf = await ImageCodec.toJpegBuffer(this.photoUri) + this.resultBase64 = await TryonApi.changeHair(buf, style.id) + this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64) + this.statusText = '试戴完成,点「对比」看原图' + } catch (e) { + this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' + } finally { + this.generating = false + } + } + + private async changeColor(): Promise { + if (this.resultBase64.length === 0 || this.generating) { + return + } + this.generating = true + const c: PresetColor = PRESET_COLORS[this.colorIdx] + this.statusText = `正在换发色(${c.name})…` + try { + const helper = new util.Base64Helper() + const pure: string = this.resultBase64.includes(',') + ? this.resultBase64.substring(this.resultBase64.indexOf(',') + 1) + : this.resultBase64 + const buf = helper.decodeSync(pure).buffer as ArrayBuffer + const rgb: string = `[${c.rgb[0]}, ${c.rgb[1]}, ${c.rgb[2]}]` + this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio) + this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64) + this.statusText = '换发色完成' + } catch (e) { + this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试' + } finally { + this.generating = false + } + } + + private async saveResult(): Promise { + if (this.resultBase64.length === 0) { + return + } + try { + const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`) + const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) + const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } + await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) + this.statusText = '已保存到相册' + } catch { + this.statusText = '已取消保存' + } + } + + build() { + Column() { + // 顶部标题栏 + Row() { + Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) + Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + Text('').width(48) + } + .width('100%') + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .justifyContent(FlexAlign.SpaceBetween) + .backgroundColor(Color.White) + + Scroll() { + Column({ space: 12 }) { + // 照片区:左原图 / 右结果 + Row({ space: 12 }) { + Column({ space: 8 }) { + if (this.photoUri.length > 0) { + Image(this.photoUri) + .width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12) + } else { + Column({ space: 6 }) { + Text('+').fontSize(28).fontColor(TEXT_SECONDARY) + Text('上传面部特写').fontSize(12).fontColor(TEXT_SECONDARY) + } + .width('100%').height(220).justifyContent(FlexAlign.Center) + .borderRadius(12).backgroundColor('#EDEFF3') + .onClick(() => { this.pickPhoto(false) }) + } + Row({ space: 8 }) { + Text('相册').fontSize(12).fontColor(ACCENT).onClick(() => { this.pickPhoto(false) }) + Text('拍照').fontSize(12).fontColor(ACCENT).onClick(() => { this.pickPhoto(true) }) + } + } + .layoutWeight(1) + + Column({ space: 8 }) { + if (this.generating) { + Column({ space: 8 }) { + LoadingProgress().width(36).height(36).color(ACCENT) + Text('生成中…').fontSize(12).fontColor(TEXT_SECONDARY) + } + .width('100%').height(220).justifyContent(FlexAlign.Center) + .borderRadius(12).backgroundColor('#EDEFF3') + } else if (this.resultPm !== undefined && !this.showOriginal) { + Image(this.resultPm) + .width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12) + } else if (this.showOriginal && this.photoUri.length > 0) { + Image(this.photoUri) + .width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12) + } else { + Column() { + Text('效果图').fontSize(12).fontColor(TEXT_SECONDARY) + } + .width('100%').height(220).justifyContent(FlexAlign.Center) + .borderRadius(12).backgroundColor('#EDEFF3') + } + Row({ space: 8 }) { + Text('对比') + .fontSize(12) + .fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY) + .onClick(() => { + if (this.resultPm !== undefined) { + this.showOriginal = !this.showOriginal + } + }) + Text('保存') + .fontSize(12) + .fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY) + .onClick(() => { this.saveResult() }) + } + } + .layoutWeight(1) + } + .width('100%') + + Text('请使用本人照片,上传即确认不侵犯他人肖像权') + .fontSize(11).fontColor(TEXT_SECONDARY).width('100%') + + if (this.statusText.length > 0) { + Text(this.statusText).fontSize(12).fontColor(TEXT_SECONDARY).width('100%') + } + + // 发型网格 + Text('选择发型(点选即试戴)').fontSize(14).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + if (this.styles.length > 0) { + Grid() { + ForEach(this.styles, (style: Hairstyle) => { + GridItem() { + Column({ space: 4 }) { + Image(TryonApi.hairstyleImageUrl(style)) + .width('100%').aspectRatio(0.72).objectFit(ImageFit.Cover) + .borderRadius(8) + .border(this.selectedStyleId === style.id + ? { width: 2, color: ACCENT } + : { width: 0 }) + Text(style.name).fontSize(11).fontColor(TEXT_PRIMARY).maxLines(1) + } + } + .onClick(() => { this.tryOn(style) }) + }, (style: Hairstyle) => style.id) + } + .columnsTemplate('1fr 1fr 1fr') + .columnsGap(8) + .rowsGap(8) + .width('100%') + } else { + Text(this.listError.length > 0 ? this.listError : '发型加载中…') + .fontSize(12).fontColor(TEXT_SECONDARY) + } + + // 换发色(有结果才可用) + if (this.resultPm !== undefined) { + Column({ space: 8 }) { + Text('换个发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + .width('100%') + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => { + Text(c.name) + .fontSize(12) + .fontColor(this.colorIdx === idx ? Color.White : TEXT_PRIMARY) + .backgroundColor(this.colorIdx === idx ? ACCENT : '#EDEFF3') + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .borderRadius(12) + .margin({ right: 8, bottom: 8 }) + .onClick(() => { this.colorIdx = idx }) + }) + } + Row({ space: 8 }) { + Text('强度').fontSize(12).fontColor(TEXT_SECONDARY) + Slider({ value: this.ratio, min: 0, max: 1, step: 0.1 }) + .layoutWeight(1) + .onChange((v: number) => { this.ratio = v }) + Text(this.ratio.toFixed(1)).fontSize(12).fontColor(TEXT_SECONDARY).width(28) + } + Button(this.generating ? '生成中…' : '应用发色') + .width('100%') + .backgroundColor(ACCENT) + .enabled(!this.generating && this.resultBase64.length > 0) + .onClick(() => { this.changeColor() }) + } + .width('100%') + .padding(12) + .borderRadius(12) + .backgroundColor(Color.White) + } + } + .width('100%') + .padding(16) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index da641a8..c403256 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -166,6 +166,8 @@ struct HomePage { .margin({ top: 8 }) this.EntryCard('生成我的形象报告', '体型 · 体态 · 穿搭建议,一次看全', '▣', ACCENT, 'pages/ReportCreatePage') + this.EntryCard('试衣间', '上传照片,换装试穿', '◈', '#E8912D', 'pages/TryonPage') + this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage') this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage') this.QuotaCard() } diff --git a/app/entry/src/main/ets/pages/TryonPage.ets b/app/entry/src/main/ets/pages/TryonPage.ets new file mode 100644 index 0000000..f3c49c1 --- /dev/null +++ b/app/entry/src/main/ets/pages/TryonPage.ets @@ -0,0 +1,226 @@ +/** + * TryonPage —— 试衣间(换装试穿),Phase A 直连线上服务。 + * + * 流程(产品方案 §2.1.1):本人全身照 + 上衣(必选)+ 裤子(可选)+ 提示词(可选) + * → 生成(长等待,显示已等待秒数,超时 320s)→ 结果对比 / 保存相册 / 再来一次。 + * 约束:只选裤子不传上衣时服务端会解码失败——UI 直接禁掉该组合。 + */ + +import { router } from '@kit.ArkUI' +import { photoAccessHelper } from '@kit.MediaLibraryKit' +import { fileUri } from '@kit.CoreFileKit' +import request from '@ohos.request' +import { TryonApi } from '../core/TryonApi' +import { ImageCodec } from '../core/ImageCodec' +import { ApiError } from '../core/ApiTypes' +import { ContextHolder } from '../core/ContextHolder' + +const PAGE_BG: string = '#F5F6F8' +const ACCENT: string = '#FF5A79' +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' + +@Entry +@Component +struct TryonPage { + @State humanUri: string = '' + @State clothUri: string = '' + @State kuziUri: string = '' + @State desc: string = '' + @State generating: boolean = false + @State elapsedSec: number = 0 + @State statusText: string = '' + @State resultUrl: string = '' + @State showOriginal: boolean = false + + private timerId: number = -1 + + aboutToAppear(): void { + // 预留:Agent 联动(V2)自动填入,见产品方案 §7。Phase A 仅支持提示词。 + const params = router.getParams() as Record | undefined + if (params !== undefined && typeof params['change_desc'] === 'string') { + this.desc = params['change_desc'] as string + } + } + + aboutToDisappear(): void { + this.stopTimer() + } + + private stopTimer(): void { + if (this.timerId >= 0) { + clearInterval(this.timerId) + this.timerId = -1 + } + } + + private async pick(slot: string, fromCamera: boolean): Promise { + try { + const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() + if (picked === undefined) { + return + } + if (slot === 'human') { + this.humanUri = picked.uri + this.resultUrl = '' + } else if (slot === 'cloth') { + this.clothUri = picked.uri + } else { + this.kuziUri = picked.uri + } + } catch (e) { + this.statusText = e instanceof ApiError ? e.message : '读取照片失败' + } + } + + private canGenerate(): boolean { + return this.humanUri.length > 0 && this.clothUri.length > 0 && !this.generating + } + + private async generate(): Promise { + if (!this.canGenerate()) { + return + } + this.generating = true + this.elapsedSec = 0 + this.resultUrl = '' + this.showOriginal = false + this.statusText = '正在压缩上传…' + this.timerId = setInterval(() => { + this.elapsedSec += 1 + }, 1000) + try { + const human = await ImageCodec.toJpegBase64(this.humanUri) + const cloth = await ImageCodec.toJpegBase64(this.clothUri) + const kuzi = this.kuziUri.length > 0 ? await ImageCodec.toJpegBase64(this.kuziUri) : '' + this.statusText = '已提交,AI 生成中(一般 1~3 分钟,请留在本页)…' + this.resultUrl = await TryonApi.changeCloth(human, cloth, kuzi, this.desc.trim()) + this.statusText = '生成完成,点「对比」看原图' + } catch (e) { + this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' + } finally { + this.stopTimer() + this.generating = false + } + } + + private async saveResult(): Promise { + if (this.resultUrl.length === 0) { + return + } + try { + const path: string = `${ContextHolder.get().cacheDir}/tryon_${Date.now()}.jpg` + const task = await request.downloadFile(ContextHolder.get(), { url: this.resultUrl, filePath: path }) + await new Promise((resolve, reject) => { + task.on('complete', () => resolve()) + task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) + }) + const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) + const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } + await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) + this.statusText = '已保存到相册' + } catch { + this.statusText = '保存失败或已取消' + } + } + + /** 三槽位选图块:label + 预览或占位,点击进相册,长按拍照(简化:下方小字两个入口)。 */ + @Builder + PhotoSlot(title: string, hint: string, uri: string, slot: string, weight: number) { + Column({ space: 6 }) { + if (uri.length > 0) { + Image(uri) + .width('100%').height(slot === 'human' ? 260 : 120).objectFit(ImageFit.Cover).borderRadius(12) + } else { + Column({ space: 6 }) { + Text('+').fontSize(24).fontColor(TEXT_SECONDARY) + Text(hint).fontSize(11).fontColor(TEXT_SECONDARY).textAlign(TextAlign.Center) + } + .width('100%').height(slot === 'human' ? 260 : 120).justifyContent(FlexAlign.Center) + .borderRadius(12).backgroundColor('#EDEFF3') + .onClick(() => { this.pick(slot, false) }) + } + Row({ space: 8 }) { + Text(title).fontSize(12).fontColor(TEXT_PRIMARY) + Text('相册').fontSize(11).fontColor(ACCENT).onClick(() => { this.pick(slot, false) }) + Text('拍照').fontSize(11).fontColor(ACCENT).onClick(() => { this.pick(slot, true) }) + } + } + .layoutWeight(weight) + } + + build() { + Column() { + Row() { + Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) + Text('试衣间').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + Text('').width(48) + } + .width('100%') + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .justifyContent(FlexAlign.SpaceBetween) + .backgroundColor(Color.White) + + Scroll() { + Column({ space: 12 }) { + // 结果优先展示;否则显示选人台 + if (this.resultUrl.length > 0) { + Column({ space: 8 }) { + Image(this.showOriginal ? this.humanUri : this.resultUrl) + .width('100%').height(380).objectFit(ImageFit.Contain) + .borderRadius(12).backgroundColor('#EDEFF3') + Row({ space: 16 }) { + Text('对比').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.showOriginal = !this.showOriginal }) + Text('保存到相册').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.saveResult() }) + Text('再来一次').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.resultUrl = '' }) + } + .width('100%').justifyContent(FlexAlign.Center) + } + .width('100%') + } + + this.PhotoSlot('我的全身照', '正面全身照\n(尽量露出手腕和腿)', this.humanUri, 'human', 3) + + Row({ space: 12 }) { + this.PhotoSlot('上衣(必选)', '平铺/挂拍单品图', this.clothUri, 'cloth', 1) + this.PhotoSlot('裤子(可选)', '选了裤子将同时换上下装', this.kuziUri, 'kuzi', 1) + } + .width('100%') + + TextInput({ text: this.desc, placeholder: '提示词(可选):如「换成户外街拍背景」' }) + .width('100%') + .onChange((v: string) => { this.desc = v }) + + Text('请使用本人照片,上传即确认不侵犯他人肖像权') + .fontSize(11).fontColor(TEXT_SECONDARY).width('100%') + + if (this.statusText.length > 0) { + Text(this.generating ? `${this.statusText}(${this.elapsedSec}s)` : this.statusText) + .fontSize(12).fontColor(TEXT_SECONDARY).width('100%') + } + + Button(this.generating ? `生成中 ${this.elapsedSec}s…` : '开始试穿') + .width('100%').height(48) + .backgroundColor(this.canGenerate() ? ACCENT : '#C2C7CE') + .enabled(this.canGenerate()) + .onClick(() => { this.generate() }) + + if (this.humanUri.length > 0 && this.kuziUri.length > 0 && this.clothUri.length === 0) { + Text('只选裤子无法生成:裤装流程需要同时提供上衣图') + .fontSize(11).fontColor('#E6A23C').width('100%') + } + } + .width('100%') + .padding(16) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/docs/试衣间与发型设计-产品方案.md b/docs/试衣间与发型设计-产品方案.md new file mode 100644 index 0000000..be9c9f7 --- /dev/null +++ b/docs/试衣间与发型设计-产品方案.md @@ -0,0 +1,122 @@ +# 试衣间与发型设计 · 产品与技术方案 + +> 2026-08-29 | 状态:Phase A 已交付(客户端直连内测版)| 关联:《产品方案》§2.1/§10.1、《技术架构文档》§4.4/§7.3/§9.3、《合规与安全方案》 + +## 1. 背景与调研结论 + +新增两个 P0 功能:**试衣间(换装试穿)** 与 **发型设计(试戴 + 换发色)**。开发前完成四路调研,结论如下。 + +### 1.1 对接目标:自有线上服务(唯一正确选择) + +| 候选 | 结论 | +|---|---| +| `meida.xiangsilian.com`(换装 + 发型) | ✅ **采用**。《产品方案》§2.1 明确指定;接口实测可用;发型 60 款在线 | +| `puton.huimeimeta.com`(老 H5 试衣间) | ❌ 素材 81% 失效(OSS 桶转私有),接口体系不同,放弃 | +| `puton-master` 代码(桌面 Web 试衣间) | 仅作交互参考:品类互斥选择、批量模式、「上衣+下装」组合逻辑可借鉴;UI 与接口层不可复用 | + +### 1.2 线上服务实测契约(2026-08-29 实测) + +**换装** `POST https://meida.xiangsilian.com/change_cloth_base64`(JSON,图片 Base64): + +| 字段 | 说明 | +|---|---| +| `human_img` | 必填,人物照片 Base64 | +| `cloth_img` | 上衣图 Base64(仅传 human=单图编辑) | +| `kuzi_img` | 裤子图;**传了必须同时传 cloth_img** | +| `change_desc` | 提示词,空=工作流默认 | + +- 同步返回 `{ret:0, data:""}`,须 `ret===0 && data` 判成功;**耗时几十秒~几分钟,客户端超时须 ≥320s**;建议图片长边压到 1024~2048。 +- 健康检查 `GET /health`;Swagger 在线试调 `/docs`。 + +**发型**(同源 `/hair/api` 前缀,multipart/form-data,**全部实测通过**): + +- `GET /hair/api/hairstyles` → 60 款 `{id, name, image_url}`(image_url 为同源相对路径); +- `POST /hair/api/change-hair`:`image`(File) + `hairstyle_id` → 结果**直接内嵌 Base64 Data URL**,实测 1056×1584 约 **5.3s**; +- `POST /hair/api/change-hair-color`:`image` + `rgb`(**字符串化数组** `"[255, 0, 0]"`,不是 hex)+ `ratio`(0~1,默认 0.9),实测 ~6s; +- ⚠️ 发型接口**不收 WebP**(换装接口收)。App 侧统一转 JPEG 再传。 + +### 1.3 素材盘点 + +- **发型库:60 款在线可用**(`GET /hair/api/hairstyles` 直接拉,无需内置); +- **服装库:不可用**——fitting-room-assets 里服装仅 2 张且是模特上身图;`reference/cloth_pic` 15 张平铺图格式标准但有品牌 logo,只做开发联调、不内置发布。Phase A 服装图**由用户现场上传/拍摄**;内置服装库依赖素材生产管线(抠图标准化 + `tryon_ready` 审核,见 §6); +- **模特:不用内置**——按合规要求「仅限本人照片」,用户上传/拍摄自己的全身照。 + +### 1.4 与架构文档的冲突与裁决 + +《技术架构文档》§10.1.4 要求「接口无认证,**禁止客户端直连,必须服务端代理**」;但后端 tryon 域目前**只有骨架**(无 `tr_task` 表、无路由、无 GPU worker)。裁决为两阶段: + +- **Phase A(本次交付,内测版)**:客户端直连线上服务。边界:仅限内测/调试构建,不公开放量;结果图不落库、不提供分享出口;不接入配额扣减(个人页配额卡保持只读展示)。目的是先把功能与体验跑起来、验证效果与耗时基线(T-P0-13/14)。 +- **Phase B(正式版前置)**:后端补齐 tryon 域(表 + 5 个 §9.3 端点 + gpu worker + 八步链路),App 切到 `/api/v1/tryon/*` 代理,届时接入配额三段式、IMS 审核、AI 水印 + 内容编号、COS 转存。Phase B 未完成的构建**不得对外发布**(合规红线:结果图必须带显式 AI 水印 + 隐式标识)。 + +## 2. 功能设计 + +### 2.1 入口 + +首页新增两张入口卡:「试衣间」(换装试穿)、「发型设计」(试戴 + 换发色),与「生成我的形象报告」「AI 穿搭顾问」并列。 + +### 2.2 试衣间流程(对齐产品方案 §2.1.1 七步) + +1. 上传/拍摄**本人正面全身照**(页面明示合规提示:仅限本人照片); +2. 上传/拍摄**上衣图**(必选)、**裤子图**(可选;选了裤子才走上下装流程); +3. 可选提示词(高级,默认折叠); +4. 点「开始试穿」→ 长等待态(显示已等待秒数 + 阶段文案,超时 320s); +5. 结果页:原图/结果左右对比(点按切换)、保存到相册、再来一次。 + +Phase A 不做:内置服装库、批量模式、专题试衣间、购买链接(这些依赖素材管线与电商对接,见 §6)。 + +### 2.3 发型设计流程(对齐产品方案 §2.1.2) + +1. 上传/拍摄**面部特写**(肩部以上,正对镜头); +2. 发型网格(在线拉 60 款,缩略图 + 名称); +3. 点选即试戴(5~10s,进度条);结果与原图点按切换对比; +4. 满意后可继续「换发色」:预设色板(自然黑/深棕/栗色/蜜茶/亚麻灰/酒红等 8 色)+ 强度滑杆(0~1,默认 0.9),换色作用于当前结果图; +5. 保存到相册 / 换一张重试。 + +### 2.4 异常与降级 + +- 换装:400 参数错误提示重拍/换图;502/503/504 提示「服务繁忙稍后重试」;超时(>320s)主动中止并提示; +- 发型:HTTP 400 按服务端 `message` 原样提示(「请上传图片」「请选择发型」等);失败保留当前图不覆盖; +- 两页均在生成前校验:已选照片、已选单品/发型,不满足则按钮禁用。 + +## 3. App 技术方案(Phase A 已实现) + +``` +ets/core/TryonApi.ets # 线上服务直连封装(换装 JSON+Base64 / 发型 multipart) +ets/core/ImageCodec.ets # 选图 → 压长边 ≤1600 → JPEG(85%) → Base64/ArrayBuffer +ets/pages/TryonPage.ets # 试衣间 +ets/pages/HairDesignPage.ets # 发型设计 +HomePage.ets # +2 入口卡;main_pages.json +2 路由 +``` + +- **图片压缩**:解码时按 desiredSize 缩到长边 1600,重编码 JPEG quality=85,Base64 体积可控(~300KB 级); +- **超时**:换装 readTimeout=320000ms;发型 readTimeout=120000ms; +- **结果展示**:换装返回 OSS URL 直接 `Image` 加载;发型返回 Base64 Data URL 解码为 PixelMap 展示; +- **保存相册**:结果先写 cacheDir,再 `photoAccessHelper.showAssetsCreationDialog`(免权限弹系统确认); +- **不在 Phase A 做**:配额扣减、任务中心、水印、分享、历史记录(线上服务无客户端可用的历史接口)。 + +## 4. 验收标准(Phase A) + +1. 构建绿(assembleHap);真机两页全流程可跑通; +2. 换装:全身照 + 上衣(±裤子)出图,等待有秒数反馈,失败有明确提示; +3. 发型:60 款列表加载、试戴 ≤15s 出图、换发色生效、可保存相册; +4. 两页均有「仅限本人照片」合规提示; +5. 实测数据回填:换装 P50 耗时、发型试戴 P50 耗时(喂给 T-P0-13/14 基线)。 + +## 5. Phase B 清单(正式版前置,未做) + +1. 后端 tryon 域:`tr_task`/`tr_favorite` 表、`POST /api/v1/tryon/tasks` 等 5 端点(架构 §9.3)、gpu 队列 worker、八步链路(§7.3); +2. 线上服务八项改造(§4.4:鉴权、任务化、幂等键、并发上限、gpu_seconds 埋点、OSS→COS、前置校验、版本号); +3. App 切代理接口,接入配额三段式(tryon 3/3/8、hairstyle 3/2/6)与消耗前剩余提示; +4. 合规出口:IMS 审核 + 「AI 生成」显式水印 + EXIF 隐式标识 + 27 位内容编号 + COS 用户桶转存(《合规与安全方案》CR-05/06); +5. 素材管线:服装抠图标准化 → `tryon_ready` 审核 → 内置服装库;60 款发型人工打适配标签(1~2 人日,T-P0-24); +6. Agent 联动(P1/V2):对话卡片「试穿此搭配」「试戴此发型」带参跳转本两页(参数预留见 §7)。 + +## 6. 素材生产需求(给运营/算法) + +- 内置服装库目标 ≥300 件 `tryon_ready`(架构风险线):拍摄 SOP(平铺白底、单品完整、无 logo 遮挡)→ matting-svc 抠图 → 运营审核; +- 模特图不需要生产(合规限定本人照片); +- `reference/cloth_pic` 仅作开发联调测试集,禁止内置(品牌 logo 版权风险)。 + +## 7. 预留:Agent 联动参数(Phase B 用,本次已留口子) + +两页 `aboutToAppear` 读取 `router.getParams()`:`TryonPage` 支持 `{cloth_img?, kuzi_img?, change_desc?}`(base64/本地路径);`HairDesignPage` 支持 `{hairstyle_id?}`。当前 Agent 卡片按钮未做(V2),参数先留。