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:
xsl
2026-08-30 13:18:15 +08:00
parent b31c7490f2
commit d9868fb4f7
6 changed files with 941 additions and 0 deletions
+123
View File
@@ -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
}
}
+164
View File
@@ -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()
}
+226
View File
@@ -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)
}
}