新增:生图流程文档
This commit is contained in:
parent
4c2d5585e3
commit
8c15058b96
@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
以首款换皮应用 **FunyMee** 为参考的 **视频首页**(`common_info` → 分类 → 按分类拉模板)数据流说明,见 **[《视频首页数据获取流程》](video_home_data_flow.md)**。
|
以首款换皮应用 **FunyMee** 为参考的 **视频首页**(`common_info` → 分类 → 按分类拉模板)数据流说明,见 **[《视频首页数据获取流程》](video_home_data_flow.md)**。
|
||||||
|
|
||||||
|
**生图 / 任务**(预签名上传、`create-task`、进度轮询、文生图、历史列表与本地封面)的端到端说明,见 **[《生图 / 任务流程》](image_generation_flow.md)**。
|
||||||
|
|
||||||
示例换皮配置文件 [`lib/src/config/skin_config.example.json`](../lib/src/config/skin_config.example.json) 的字段说明,见 **[《skin_config.example.json 字段说明》](skin_config_example.md)**。
|
示例换皮配置文件 [`lib/src/config/skin_config.example.json`](../lib/src/config/skin_config.example.json) 的字段说明,见 **[《skin_config.example.json 字段说明》](skin_config_example.md)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
108
docs/image_generation_flow.md
Normal file
108
docs/image_generation_flow.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 图片类生成任务:换皮应用通用流程
|
||||||
|
|
||||||
|
本文描述「用户图 + 模板生成图片/视频类任务」在换皮应用中的**通用产品与技术流程**:选图方式、选图后界面表现、上传与创建任务的数据链路、以及上传后与进度相关的界面跳转。具体接口字段、加解密与换皮命名以各应用的配置与《客户端指南》为准;本文不绑定某一仓库内的源码路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 流程总览
|
||||||
|
|
||||||
|
典型端到端路径可以概括为:
|
||||||
|
|
||||||
|
1. **选图**:用户从**相机**或**系统相册**取得一张或多张本地图片(依业务是否支持双图等)。
|
||||||
|
2. **选图后界面**:展示预览、可选编辑/压缩提示、确认或重选;进入上传前可展示加载或禁用重复提交。
|
||||||
|
3. **上传与创建任务**:先拿到服务端下发的**预签名上传地址**,客户端**直传对象存储**,再用服务端返回的**文件路径**调用**创建任务**,得到 **`taskId`**。
|
||||||
|
4. **进度与结果**:对 **`taskId` 轮询进度**,直到成功、失败或超时;界面随状态切换(结果页、重试、返回列表等)。
|
||||||
|
5. **历史**:任务列表拉取远端记录,必要时与**本地封面缓存**合并展示。
|
||||||
|
|
||||||
|
与「仅文案、无用户图」的**文生图**相比,主路径多了「本地文件 → 预签名上传 → 创建任务」这一段;创建成功之后的进度与列表逻辑通常一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 选图方式:相机与相册
|
||||||
|
|
||||||
|
| 方式 | 常见行为 | 产品注意点 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| **相册** | 调起系统图片选择器,用户勾选一张或按业务限制多张 | 需处理权限拒绝、无可用图片、超大图;可在此步限制格式(如仅图片) |
|
||||||
|
| **相机** | 调起系统相机,拍摄后得到临时文件或相册引用 | 需处理权限、取消拍摄、低内存;部分系统会先落相册再选 |
|
||||||
|
|
||||||
|
换皮应用应在交互上明确:**当前是「拍一张」还是「从相册选」**,避免用户混淆;若业务只允许单图,应在选图结果回调里统一成单文件路径再进入下一步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 选图后的 UI 表现(建议)
|
||||||
|
|
||||||
|
选图成功、尚未开始上传时,建议统一呈现:
|
||||||
|
|
||||||
|
- **预览区**:展示已选图片缩略图或全屏预览;双图业务需区分「图一 / 图二」标签或顺序说明。
|
||||||
|
- **可编辑项**(若有):分辨率、模板、文案提示等;与后端 `create-task` 字段对应的选项应在此收集完毕。
|
||||||
|
- **主操作**:例如「生成」「下一步」;点击前应做基础校验(必填项、文件是否存在)。
|
||||||
|
- **次要操作**:「重新选图」清空当前选择回到选图入口;避免在上传中途允许再次选图 unless 有取消上传的完整逻辑。
|
||||||
|
- **状态**:从点击「开始生成」到拿到 `taskId` 之前,展示**不可重复提交**的加载态(按钮置灰、全屏 Loading 等),防止重复创建任务。
|
||||||
|
|
||||||
|
若客户端会在上传前做**压缩**(例如限制长边、JPEG 质量),失败时应有**可理解的错误提示**,并允许用户重试或重选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 上传图片的数据流程(概念)
|
||||||
|
|
||||||
|
主路径可抽象为四步(与具体语言、类名无关):
|
||||||
|
|
||||||
|
1. **(可选)压缩**:对本地文件做尺寸/质量压缩,减少上传时间与流量。
|
||||||
|
2. **申请预签名**:请求服务端 **`upload-presigned-url`** 类接口,携带文件名、内容类型、预期大小等;服务端返回 **上传 URL**、**上传后文件在服务端的路径标识**、以及 **PUT 时需要的头信息**。
|
||||||
|
3. **直传对象存储**:客户端对返回的 **上传 URL** 发起 **HTTP PUT**,请求体为图片字节流;请求头需与服务端约定一致(例如 `Content-Type` 与附加头合并),避免签名失败。
|
||||||
|
4. **创建任务**:再请求 **`create-task`** 类接口,将上一步得到的 **文件路径**(以及业务字段如分辨率、任务类型、模板名、提示词等)提交;响应中解析 **`taskId`**。
|
||||||
|
|
||||||
|
双图源场景:对**每张图**重复「预签名 → PUT」,创建任务时分别填入「第一张 / 第二张」对应字段;若产品需要「仅首张作列表封面」,本地展示策略可与业务约定一致。
|
||||||
|
|
||||||
|
创建任务成功后,可选地**把本次用于上传的本地文件拷贝到应用私有目录**,按 `taskId` 命名,用于任务列表在**远端封面尚未返回时**的占位展示;该类缓存通常带**过期清理**策略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 上传完成后界面如何跳转
|
||||||
|
|
||||||
|
建议的通用分支:
|
||||||
|
|
||||||
|
| 结果 | UI 行为 |
|
||||||
|
|------|---------|
|
||||||
|
| **拿到 `taskId`** | 进入**进度页**(或带进度条的全屏态),并开始对 `taskId` **轮询进度**;同时可跳转到「我的任务」并插入一条「进行中」占位项。 |
|
||||||
|
| **上传或创建任务失败** | 停留在当前页或返回预览页,**明确错误原因**(网络、鉴权、业务拒绝等),提供**重试**与**重选图**。 |
|
||||||
|
| **需要离开当前页** | 若采用「列表驱动」体验,可在创建成功后**直接跳到历史列表**,并在列表项上展示进度;核心是用户始终能感知**任务已创建**且**不会丢**。 |
|
||||||
|
|
||||||
|
文生图(无用户文件)跳过上传步骤,在拿到 `taskId` 后的跳转逻辑可与上表一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 进度轮询与完成态 UI
|
||||||
|
|
||||||
|
- **轮询**:按固定间隔请求 **`progress`** 类接口(间隔需避免重叠请求,即上一帧结束再拉下一帧);网络瞬时失败可有限次重试,超过阈值则提示用户检查网络或稍后从历史进入。
|
||||||
|
- **停止轮询**:当出现**可用结果 URL**、或状态为**成功/失败/超时/中止**等等价终态时,应停止轮询,避免无效请求。
|
||||||
|
- **成功**:展示结果图或跳转结果页;可提供保存、分享、再做一张等入口。
|
||||||
|
- **失败或超时**:提示原因,提供**重试**(是否新建任务依产品)或返回模板/首页。
|
||||||
|
|
||||||
|
进度数据结构通常包含:任务标识、状态码或枚举、进度百分比、结果图 URL 等;界面应以「可展示结果」与「明确失败」为优先判断,避免仅依赖单一字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 任务列表与本地封面(概念)
|
||||||
|
|
||||||
|
- **远端列表**:分页拉取「我的任务」类接口,展示缩略图、状态、时间等。
|
||||||
|
- **与本地封面合并**:若创建成功时曾写入本地封面,列表在接口尚未返回远程封面 URL 时,可优先或回退显示本地文件,提升「刚提交就去看列表」的体验。
|
||||||
|
- **一致性**:任务终态后应以服务端返回的 URL 为准;本地缓存可按时间与容量策略清理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 集成方需对齐的要点(清单)
|
||||||
|
|
||||||
|
- 选图入口:**相机 / 相册**权限与取消流程。
|
||||||
|
- 选图后:**预览、参数、防重复提交**。
|
||||||
|
- 上传链:**预签名 → PUT → create-task**,PUT 头与 body 与服务端一致。
|
||||||
|
- 创建后:**进度 UI** 与 **`taskId`** 维度的轮询停止条件。
|
||||||
|
- 列表:**远端数据**与可选的**本地封面**合并策略。
|
||||||
|
- 换皮差异:**接口 host、鉴权、字段映射**以各应用配置与对外文档为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 对外文档交叉引用
|
||||||
|
|
||||||
|
- 换皮应用功能清单、推荐封装方式:见各宿主仓库的开发手册中「图片 / 任务」相关章节。
|
||||||
|
- 接口字段与换皮命名:见各宿主仓库的客户端对接指南中 `upload-presigned-url`、`create-task`、`progress`、`txt2img_create` 等关键词说明。
|
||||||
@ -17,6 +17,7 @@ class PaymentProductItem extends Entity {
|
|||||||
final String? activityId;
|
final String? activityId;
|
||||||
final String? actualAmount;
|
final String? actualAmount;
|
||||||
final String? originAmount;
|
final String? originAmount;
|
||||||
|
/// 线网 `contrast`→`bonus`:赠送积分数(数量,非百分比);与 [bonusCredits] 二选一或并存由接口决定。
|
||||||
final int? bonus;
|
final int? bonus;
|
||||||
/// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。
|
/// 额外赠送积分(换皮线网常为 `saturation` → `bonusCredits`)。
|
||||||
final int? bonusCredits;
|
final int? bonusCredits;
|
||||||
@ -36,12 +37,26 @@ class PaymentProductItem extends Entity {
|
|||||||
actualAmount: _stringField(json, 'actualAmount'),
|
actualAmount: _stringField(json, 'actualAmount'),
|
||||||
originAmount: _stringField(json, 'originAmount'),
|
originAmount: _stringField(json, 'originAmount'),
|
||||||
bonus: _intField(json, 'bonus'),
|
bonus: _intField(json, 'bonus'),
|
||||||
bonusCredits: _intField(json, 'bonusCredits'),
|
bonusCredits: _parseBonusCredits(json),
|
||||||
title: _stringField(json, 'title'),
|
title: _stringField(json, 'title'),
|
||||||
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
|
credits: _intField(json, 'credits') ?? _intField(json, 'padding'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `saturation`→`bonusCredits`、`remix`→`addCredits`、`contrast`→`bonus`(赠送积分数),或 `credits`−`baseCredits`。
|
||||||
|
static int? _parseBonusCredits(Map<String, dynamic> json) {
|
||||||
|
final direct = _intField(json, 'bonusCredits');
|
||||||
|
if (direct != null && direct > 0) return direct;
|
||||||
|
final add = _intField(json, 'addCredits');
|
||||||
|
if (add != null && add > 0) return add;
|
||||||
|
final contrastGift = _intField(json, 'bonus');
|
||||||
|
if (contrastGift != null && contrastGift > 0) return contrastGift;
|
||||||
|
final total = _intField(json, 'credits') ?? _intField(json, 'padding');
|
||||||
|
final base = _intField(json, 'baseCredits');
|
||||||
|
if (total != null && base != null && total > base) return total - base;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static String? _stringField(Map<String, dynamic> json, String key) {
|
static String? _stringField(Map<String, dynamic> json, String key) {
|
||||||
final v = json[key];
|
final v = json[key];
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
@ -140,12 +155,12 @@ class PaymentMethodItem extends Entity {
|
|||||||
String? get bonusLabel {
|
String? get bonusLabel {
|
||||||
final bc = bonusCredits;
|
final bc = bonusCredits;
|
||||||
if (bc != null && bc > 0) {
|
if (bc != null && bc > 0) {
|
||||||
return '+$bc bonus credits';
|
return '+$bc More Credits';
|
||||||
}
|
}
|
||||||
final br = bonusRatio;
|
final br = bonusRatio;
|
||||||
if (br != null && br > 0) {
|
if (br != null && br > 0) {
|
||||||
final pct = br <= 1 ? (br * 100).round() : br.round();
|
final pct = br <= 1 ? (br * 100).round() : br.round();
|
||||||
return '+$pct% bonus credits';
|
return '+$pct% More Credits';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,11 @@ abstract class FrameworkAuthService {
|
|||||||
/// 登录是否已完成
|
/// 登录是否已完成
|
||||||
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
static final ValueNotifier<bool> isLoginComplete = ValueNotifier(false);
|
||||||
|
|
||||||
|
/// 最近一次快速登录成功时的 [FastLoginResponse.userId](失败或未登录为空)。
|
||||||
|
///
|
||||||
|
/// 宿主在调用需 `userId` 的接口(如生图 `create-task`)前可读取;若为空应提示用户稍后重试。
|
||||||
|
static String? lastLoggedInUserId;
|
||||||
|
|
||||||
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
/// 登录完成后的 Future,需鉴权接口应 await 此 Future 再请求
|
||||||
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
static Future<void> get loginComplete => _loginFuture ?? Future<void>.value();
|
||||||
|
|
||||||
@ -152,6 +157,7 @@ abstract class FrameworkAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res == null) {
|
if (res == null) {
|
||||||
|
lastLoggedInUserId = null;
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
completer.complete();
|
completer.complete();
|
||||||
@ -164,6 +170,9 @@ abstract class FrameworkAuthService {
|
|||||||
|
|
||||||
if (res.isSuccess && res.data != null) {
|
if (res.isSuccess && res.data != null) {
|
||||||
final loginData = res.data!;
|
final loginData = res.data!;
|
||||||
|
final uid = loginData.userId?.trim();
|
||||||
|
lastLoggedInUserId =
|
||||||
|
uid != null && uid.isNotEmpty ? uid : null;
|
||||||
|
|
||||||
// 设置 Token
|
// 设置 Token
|
||||||
if (loginData.userToken != null && loginData.userToken!.isNotEmpty) {
|
if (loginData.userToken != null && loginData.userToken!.isNotEmpty) {
|
||||||
@ -182,11 +191,13 @@ abstract class FrameworkAuthService {
|
|||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
lastLoggedInUserId = null;
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
_callbacks!.onLoginFailed(res.msg);
|
_callbacks!.onLoginFailed(res.msg);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
lastLoggedInUserId = null;
|
||||||
VideoHomeRuntime.reset();
|
VideoHomeRuntime.reset();
|
||||||
ExtConfigRuntime.applyCommonInfoFailure();
|
ExtConfigRuntime.applyCommonInfoFailure();
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
|||||||
@ -317,10 +317,12 @@ abstract final class ImageApi {
|
|||||||
/// 获取积分页面信息(与 `UserApi.getCreditsPage` 相同,走 `GET /v1/user/credits-page`)
|
/// 获取积分页面信息(与 `UserApi.getCreditsPage` 相同,走 `GET /v1/user/credits-page`)
|
||||||
///
|
///
|
||||||
/// 保留此方法以兼容旧调用;[app]、[userId]、[ch] 不再参与请求(接口只吃分页参数)。
|
/// 保留此方法以兼容旧调用;[app]、[userId]、[ch] 不再参与请求(接口只吃分页参数)。
|
||||||
|
///
|
||||||
|
/// [type] 可选;不传时不在 query 中带 `type`,由服务端默认处理。
|
||||||
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPageInfo({
|
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPageInfo({
|
||||||
String page = '1',
|
String page = '1',
|
||||||
String size = '10',
|
String size = '10',
|
||||||
String type = '1',
|
String? type,
|
||||||
String? app,
|
String? app,
|
||||||
String? userId,
|
String? userId,
|
||||||
String? ch,
|
String? ch,
|
||||||
@ -332,7 +334,7 @@ abstract final class ImageApi {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'size': size,
|
'size': size,
|
||||||
'type': type,
|
if (type != null && type.isNotEmpty) 'type': type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,10 +141,12 @@ abstract final class UserApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 积分分页流水(`GET /v1/user/credits-page`)
|
/// 积分分页流水(`GET /v1/user/credits-page`)
|
||||||
|
///
|
||||||
|
/// [type] 可选;不传时不在 query 中带 `type`,由服务端默认处理。
|
||||||
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPage({
|
static Future<EntityResponse<CreditsPageInfoResponse>> getCreditsPage({
|
||||||
String page = '1',
|
String page = '1',
|
||||||
String size = '10',
|
String size = '10',
|
||||||
String type = '1',
|
String? type,
|
||||||
}) async {
|
}) async {
|
||||||
return _client.requestEntity(
|
return _client.requestEntity(
|
||||||
path: '/v1/user/credits-page',
|
path: '/v1/user/credits-page',
|
||||||
@ -153,7 +155,7 @@ abstract final class UserApi {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
'page': page,
|
'page': page,
|
||||||
'size': size,
|
'size': size,
|
||||||
'type': type,
|
if (type != null && type.isNotEmpty) 'type': type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user