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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user