feat(tryon): 试衣间 + 发型设计(Phase A 直连线上 API)
- TryonApi.ets:直连 meida.xiangsilian.com——换装 /change_cloth_base64(JSON+Base64,超时 320s)、发型 /hair/api/*(multipart,实测 5~10s) - ImageCodec.ets:相册选图→解码→缩放→Base64 的公共链路 - TryonPage(模特/服装双图换装)+ HairDesignPage(60 款发型试戴/换发色),首页两张入口卡 - docs/试衣间与发型设计-产品方案.md:Phase A 直连内测 / Phase B 服务端代理 + 合规纳管的分工结论 - 待补:真机实测耗时(T-P0-13/14 基线);Phase B 迁服务端时代码改动集中在 TryonApi 一处
This commit is contained in:
@@ -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<PickedImage | undefined> {
|
||||
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<PickedImage | undefined> {
|
||||
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<PickedImage> {
|
||||
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<ArrayBuffer> {
|
||||
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<string> {
|
||||
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<image.PixelMap> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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/<id>.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<Hairstyle[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const client = http.createHttp()
|
||||
try {
|
||||
const body: Record<string, Object> = {
|
||||
'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<string> {
|
||||
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, '请稍后重试')
|
||||
}
|
||||
}
|
||||
@@ -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<string, Object> | undefined
|
||||
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
|
||||
this.selectedStyleId = params['hairstyle_id'] as string
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStyles(): Promise<void> {
|
||||
try {
|
||||
this.styles = await TryonApi.listHairstyles()
|
||||
} catch (e) {
|
||||
this.listError = e instanceof ApiError ? e.message : '发型列表加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
private async pickPhoto(fromCamera: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<string, Object> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((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)
|
||||
}
|
||||
}
|
||||
@@ -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:"<OSS签名URL>"}`,须 `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),参数先留。
|
||||
Reference in New Issue
Block a user