修改:新增抖音下单页面
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing

This commit is contained in:
Linzm committed 2026-10-20 15:17:22 +08:00
1 parent 024c8a5155
commit 73971e4a08
38 files changed
+14447 -35505

No files matched your search

+3 -1
View File
@@ -1,7 +1,9 @@
VITE_APP_PREVIEW=true
# 开发环境走 Vite 同源代理 /api,避免直连 shop.api 跨域(后端 CORS 头重复冲突)
# VITE_APP_API_BASE_URL=http://172.16.20.21:20001/api/web/
# VITE_APP_API_BASE_URL=http://172.16.0.40:20001/api/web/
VITE_APP_API_BASE_URL=https://shop.api.suwa3d.com/api/web/
# VITE_APP_API_BASE_URL=https://shop.api.suwa3d.com/api/web/
VITE_APP_API_BASE_URL=/api/
# VITE_APP_API_BASE_URL=https://wechat-test.api.puabadge.com/web/
# VITE_APP_API_BASE_URL=https://wechat.api.puabadge.com/web/
# VITE_APP_API_WX_URL=https://wechat.api.puabadge.com/web/
+6 -6
View File
@@ -10,7 +10,7 @@
### 包管理器
推荐使用 [pnpm](https://pnpm.io/) 来进行包管理,可以极大地减少install时间, 而且非常节省磁盘空间。
推荐使用 [yarn](https://yarnpkg.com/) 来进行包管理。
### 目录结构
@@ -63,17 +63,17 @@ git clone https://github.com/CharleeWa/vue3-vant-mobile.git
```bash
cd vue3-vant-mobile
# with pnpm
pnpm install
# with yarn
yarn install
```
> 如果网络状况不佳,可以设置 pnpm 的 npm 加载源,如 pnpm config set registry https://registry.npmmirror.com
> 如果网络状况不佳,可以设置 yarn 的 npm 加载源,如 `yarn config set registry https://registry.npmmirror.com`
完成前面的步骤以后,就可以启动项目,开始进行业务开发了。
```bash
pnpm dev
yarn dev
```
### 新增页面
@@ -87,7 +87,7 @@ pnpm dev
这里已经为大家预置了两种:`view` 和 `component`,选择一个之后按回车键就可以在项目目录的左侧看到了。
```bash
pnpm plop
yarn plop
```
- 手动创建
+5 -5
View File
@@ -31,7 +31,7 @@
- 🚀 Vue3 生态开箱即用
- 🤖 自动导入组件
- 📄 使用plop轻松创建文件
- 📦 使用pnpm包管理器
- 📦 使用 yarn 包管理器
- 💾 支持Mock服务器
- 🌈 支持Git hooks(lint和commit)
- 💖 移动端UI组件(Vant4)
@@ -50,21 +50,21 @@ git clone https://github.com/CharleeWa/vue3-vant-mobile.git
```bash
cd vue3-vant-mobile
# with pnpm
pnpm install
# with yarn
yarn install
```
- 运行
```bash
pnpm dev
yarn dev
```
- 打包构建
```bash
pnpm build
yarn build
```
## 浏览器支持
+5 -5
View File
@@ -31,7 +31,7 @@ English / [简体中文](./README-zh_CN.md) , [使用指南](./Guide-zh_CN.md) /
- 🚀 Support Vue 3 out-of-the-box
- 🤖 Auto Importing components
- 📄 Easy to create files (plop)
- 📦 Using pnpm package manager
- 📦 Using yarn package manager
- 💾 Support Mock server
- 🌈 Support Git hooks (lint and commit)
- 💖 Mobile UI Components (Vant4)
@@ -51,21 +51,21 @@ git clone https://git.suwa3d.com/natuka/mobile.suwa3d.com.git
```bash
cd mobile.suwa3d.com
# with pnpm
pnpm install
# with yarn
yarn install
```
- run
```bash
pnpm dev
yarn dev
```
- build
```bash
pnpm build
yarn build
```
## Browser Support
+12
View File
@@ -13,6 +13,8 @@ declare module '@vue/runtime-core' {
Backup: typeof import('./src/components/arFrame/backup.vue')['default']
Backup2: typeof import('./src/components/arFrame/backup2.vue')['default']
Brand: typeof import('./src/components/brand/index.vue')['default']
Constants: typeof import('./src/views/douyin/components/image-point-cloud/constants.js')['default']
DyNavBar: typeof import('./src/views/douyin/components/DyNavBar.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -24,11 +26,21 @@ declare module '@vue/runtime-core' {
ElTabs: typeof import('element-plus/es')['ElTabs']
Gsplat: typeof import('./src/components/arFrame/gsplat.vue')['default']
Hint: typeof import('./src/components/hint/hint.vue')['default']
ImagePointCloud: typeof import('./src/views/douyin/components/ImagePointCloud.vue')['default']
Loading: typeof import('./src/components/loading/index.vue')['default']
LogoCloudCache: typeof import('./src/views/douyin/components/image-point-cloud/logo-cloud-cache.js')['default']
MaskProcessor: typeof import('./src/views/douyin/components/image-point-cloud/mask-processor.js')['default']
Model: typeof import('./src/components/model/index.vue')['default']
ModelGsplat: typeof import('./src/components/modelGsplat/index.vue')['default']
PointCloudSampler: typeof import('./src/views/douyin/components/image-point-cloud/point-cloud-sampler.js')['default']
PointCloudWait: typeof import('./src/views/douyin/components/PointCloudWait.vue')['default']
Renderer: typeof import('./src/views/douyin/components/image-point-cloud/webgl/renderer.js')['default']
Shaders: typeof import('./src/views/douyin/components/image-point-cloud/webgl/shaders.js')['default']
State: typeof import('./src/views/douyin/components/image-point-cloud/state.js')['default']
SubjectOutline: typeof import('./src/views/douyin/components/image-point-cloud/subject-outline.js')['default']
Tabbar: typeof import('./src/components/tabbar/index.vue')['default']
Tensorflow: typeof import('./src/components/arFrame/tensorflow.vue')['default']
TextureManager: typeof import('./src/views/douyin/components/image-point-cloud/webgl/texture-manager.js')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanAddressEdit: typeof import('vant/es')['AddressEdit']
VanDivider: typeof import('vant/es')['Divider']
-25169
View File
File diff suppressed because it is too large. Load diff
+1 -8
View File
@@ -18,7 +18,7 @@
"preview": "vite preview",
"test": "vitest",
"plop": "plop",
"prepare": "npx husky install"
"prepare": "husky install"
},
"dependencies": {
"@google/model-viewer": "^3.4.0",
@@ -111,13 +111,6 @@
"vitest": "^0.25.8",
"vue-tsc": "^1.8.27"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"postcss"
]
}
},
"config": {
"commitizen": {
"path": "./node_modules/cz-emoji-chinese"
-10304
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
import fs from 'fs'
import path from 'path'
const root = path.resolve('src/views/douyin/components/image-point-cloud')
function walk(d) {
for (const f of fs.readdirSync(d)) {
const p = path.join(d, f)
if (fs.statSync(p).isDirectory()) walk(p)
else if (f.endsWith('.js')) convert(p)
}
}
function convert(file) {
let s = fs.readFileSync(file, 'utf8')
s = s.replace(
/const\s+\{([^}]+)\}\s*=\s*require\((['"])(.*?)\2\)/g,
(_m, names, _q, mod) => `import {${names}} from '${mod}'`
)
s = s.replace(
/const\s+(\w+)\s*=\s*require\((['"])(.*?)\2\)/g,
(_m, name, _q, mod) => `import * as ${name} from '${mod}'`
)
// remove config perf require block
s = s.replace(
/try\s*\{[\s\S]*?POINT_CLOUD_PERF_LOG[\s\S]*?\}\s*catch[\s\S]*?\{[\s\S]*?\}/,
'let _perfLog = false'
)
s = s.replace(/module\.exports\s*=\s*\{([\s\S]*?)\}\s*$/m, (_m, body) => `export {${body}}`)
s = s.replace(/module\.exports\s*=\s*(\w+)\s*$/m, 'export default $1')
fs.writeFileSync(file, s)
console.log('converted', file)
}
walk(root)
+387
View File
@@ -0,0 +1,387 @@
import request from '@/utils/request'
const PREFIX = 'douyin'
function normalizeOssUrl(url: string) {
if (!url) return ''
return String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=')
}
function guessContentType(file: File | Blob, filename?: string) {
if (file.type) return file.type
const name = String(filename || '').toLowerCase()
if (name.endsWith('.png')) return 'image/png'
if (name.endsWith('.webp')) return 'image/webp'
if (name.endsWith('.gif')) return 'image/gif'
return 'image/jpeg'
}
function guessFilename(file?: File) {
if (file && (file as File).name) return (file as File).name
return 'photo.jpg'
}
/** POST /douyin/upload/url */
export const getUploadUrl = (data: { filename?: string; content_type?: string } = {}) => {
return request(`${PREFIX}/upload/url`, {
method: 'POST',
data: {
content_type: data.content_type || 'image/jpeg',
...(data.filename ? { filename: data.filename } : {}),
},
})
}
/** POST /douyin/upload/complete */
export const completeUpload = (data: { upload_id: string }) => {
return request(`${PREFIX}/upload/complete`, {
method: 'POST',
data: { upload_id: data.upload_id },
})
}
/** POST /douyin/imagegen/submit */
export const submitImageGen = (data: {
upload_id: string
prompt?: string
style?: string
n?: number
}) => {
const body: any = { upload_id: data.upload_id }
if (data.prompt) body.prompt = String(data.prompt).slice(0, 4000)
if (data.style) body.style = String(data.style).slice(0, 200)
if (data.n != null) body.n = Math.min(4, Math.max(1, Number(data.n) || 1))
return request(`${PREFIX}/imagegen/submit`, {
method: 'POST',
data: body,
})
}
/** GET /douyin/imagegen/status */
export const getImageGenStatus = (taskId: string) => {
return request(`${PREFIX}/imagegen/status`, {
method: 'GET',
params: { task_id: taskId },
})
}
/** GET /douyin/imagegen/records */
export const getImageGenRecords = (params: { page?: number; page_size?: number } = {}) => {
return request(`${PREFIX}/imagegen/records`, {
method: 'GET',
params: {
page: Math.max(1, Number(params.page) || 1),
page_size: Math.max(1, Number(params.page_size) || 20),
},
})
}
/**
* POST /douyin/make/submit
* 下单/提交制作:选定效果图,参数仅 task_id + image_id
*/
export const submitMake = (data: { task_id: string; image_id: string }) => {
const taskId = String(data.task_id || '').trim()
const imageId = String(data.image_id || '').trim()
if (!taskId) return Promise.reject(new Error('缺少 task_id'))
if (!imageId) return Promise.reject(new Error('缺少 image_id'))
return request(`${PREFIX}/make/submit`, {
method: 'POST',
data: {
task_id: taskId,
image_id: imageId,
},
})
}
/**
* 解析 make/submit 所需 image_id;本地缺失时拉生图状态补齐
*/
export async function resolveMakeImageId(
taskId: string,
imageId?: string | number | null,
): Promise<string> {
const local = String(imageId || '').trim()
if (local) return local
const tid = String(taskId || '').trim()
if (!tid) return ''
const res: any = await getImageGenStatus(tid)
const parsed = parseImageGenStatus(res, tid)
return String(parsed.imageId || '').trim()
}
/**
* 抖音下单入口:自动补齐 image_id 后调用 make/submit
*/
export async function submitProduction(data: {
task_id: string
image_id?: string | number | null
}) {
const taskId = String(data.task_id || '').trim()
if (!taskId) throw new Error('缺少 task_id')
const imageId = await resolveMakeImageId(taskId, data.image_id)
if (!imageId) throw new Error('缺少效果图 ID,请返回重试生成')
const res = await submitMake({ task_id: taskId, image_id: imageId })
return {
taskId,
imageId,
raw: res,
}
}
/** POST /douyin/mask/create */
export const createMask = (data: { pid: number | string }) => {
return request(`${PREFIX}/mask/create`, {
method: 'POST',
data: { pid: data.pid },
})
}
/** GET /douyin/mask/url */
export const getMaskUrl = (params: { pid: number | string }) => {
return request(`${PREFIX}/mask/url`, {
method: 'GET',
params: { pid: params.pid },
}).then((data: any) => {
const d = data || {}
return {
pid: d.pid != null ? d.pid : params.pid,
path: d.path || '',
url: normalizeOssUrl(d.url || ''),
ready: !!(d.ready === true || d.ready === 1 || d.ready === 'true'),
}
})
}
/** create 后轮询 mask/url,直到 ready 且有 url */
export async function waitMaskUrl(options: {
pid: number | string
intervalMs?: number
timeoutMs?: number
}) {
const pid = options.pid
if (pid == null || pid === '') throw new Error('缺少 pid')
const intervalMs = options.intervalMs != null ? options.intervalMs : 2000
const timeoutMs = options.timeoutMs != null ? options.timeoutMs : 60000
const started = Date.now()
try {
await createMask({ pid })
} catch (e) {
// create 失败仍尝试拉 url(任务可能已存在)
}
const poll = async (): Promise<{ pid: any; path: string; url: string; ready: boolean }> => {
if (Date.now() - started > timeoutMs) throw new Error('Mask 生成超时')
const res = await getMaskUrl({ pid })
if (res && res.ready && res.url) return res
await new Promise((r) => setTimeout(r, intervalMs))
return poll()
}
return poll()
}
export function parseUploadUrlResult(res: any) {
const data = res || {}
return {
uploadId: data.upload_id || data.uploadId || '',
uploadUrl: normalizeOssUrl(data.upload_url || data.uploadUrl || data.put_url || data.putUrl || ''),
fileUrl: normalizeOssUrl(data.file_url || data.fileUrl || ''),
contentType: data.content_type || data.contentType || 'image/jpeg',
pid: data.pid != null ? data.pid : '',
expireAt: Number(data.expire_at || data.expireAt || 0),
}
}
export function parseImageGenStatus(res: any, taskId = '') {
const data = res || {}
const statusRaw = String(data.status || data.state || data.task_status || '').toLowerCase()
const images = data.images || data.results || data.result_images || data.outputs || []
const firstImage = Array.isArray(images) && images.length ? images[0] : null
const resultUrl =
data.result_url ||
data.resultUrl ||
data.image_url ||
data.imageUrl ||
data.file_url ||
data.fileUrl ||
(firstImage &&
(typeof firstImage === 'string'
? firstImage
: firstImage.url ||
firstImage.image_url ||
firstImage.imageUrl ||
firstImage.result_url ||
firstImage.file_url)) ||
''
const imageId =
data.image_id ||
data.imageId ||
data.selected_image_id ||
data.result_image_id ||
(firstImage &&
typeof firstImage === 'object' &&
(firstImage.image_id || firstImage.imageId || firstImage.id || firstImage.img_id)) ||
''
let status = 'processing'
if (['success', 'succeeded', 'done', 'completed', 'finished'].includes(statusRaw)) {
status = 'success'
} else if (['failed', 'fail', 'error', 'canceled', 'cancelled'].includes(statusRaw)) {
status = 'failed'
} else if (['pending', 'queued', 'created', 'waiting'].includes(statusRaw)) {
status = 'pending'
} else if (['processing', 'running', 'generating'].includes(statusRaw)) {
status = 'processing'
}
if (resultUrl && status !== 'failed') status = 'success'
return {
taskId: data.task_id || data.taskId || taskId,
status,
statusRaw,
resultUrl: normalizeOssUrl(resultUrl),
imageId: String(imageId || ''),
message: data.message || data.msg || '',
raw: data,
}
}
/** 原图上传:upload/url → PUT → upload/complete */
export async function uploadOriginal(file: File | Blob, options: {
filename?: string
content_type?: string
} = {}) {
const filename = options.filename || guessFilename(file as File)
const contentType = options.content_type || guessContentType(file, filename)
const res: any = await getUploadUrl({ filename, content_type: contentType })
const parsed = parseUploadUrlResult(res)
if (!parsed.uploadId) throw new Error('签发上传地址失败:缺少 upload_id')
if (!parsed.uploadUrl) throw new Error('签发上传地址失败:缺少 upload_url')
if (parsed.expireAt > 0) {
const now = Math.floor(Date.now() / 1000)
if (now >= parsed.expireAt - 5) throw new Error('上传地址已过期,请重试')
}
const putRes = await fetch(parsed.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': parsed.contentType || contentType },
body: file,
})
if (!putRes.ok) throw new Error('上传失败')
await completeUpload({ upload_id: parsed.uploadId })
return {
upload_id: parsed.uploadId,
file_url: parsed.fileUrl,
pid: parsed.pid,
content_type: parsed.contentType || contentType,
}
}
/** 上传 + 提交生图 */
export async function startGeneratePipeline(file: File | Blob, options: {
filename?: string
content_type?: string
prompt?: string
style?: string
n?: number
} = {}) {
const uploadRes = await uploadOriginal(file, options)
const data: any = await submitImageGen({
upload_id: uploadRes.upload_id,
prompt: options.prompt,
style: options.style || '坐姿手办风',
n: options.n || 1,
})
const taskId = data.task_id || data.taskId
if (!taskId) throw new Error('提交生图任务失败')
return {
taskId: String(taskId),
upload_id: uploadRes.upload_id,
file_url: uploadRes.file_url || '',
pid: uploadRes.pid,
status: data.status || 'pending',
raw: data,
}
}
/** 规范化记录列表 */
export function normalizeRecords(res: any) {
const data = res || {}
let list: any[] = []
let total = 0
let page = 1
let pageSize = 20
if (Array.isArray(data)) {
list = data
total = data.length
} else {
list = data.list || data.records || data.items || data.rows || []
if (!Array.isArray(list)) list = []
total = data.total != null ? Number(data.total) : data.count != null ? Number(data.count) : list.length
page = Number(data.page) || 1
pageSize = Number(data.page_size || data.pageSize) || 20
}
const mapped = list.map((item: any) => {
const statusRaw = String(item.status || item.state || '').toLowerCase()
let status = 1
if (['failed', 'fail', 'error'].includes(statusRaw) || item.status === 2) status = 2
else if (['pending', 'processing', 'running', 'queued', 'generating'].includes(statusRaw) || item.status === 0) status = 0
else if (item.status === 1 || ['success', 'succeeded', 'done', 'completed', 'submitted', 'generated'].includes(statusRaw)) status = 1
// 接口图片在 images[] 里:{ image_id, url },与 parseImageGenStatus 保持一致
const images = item.images || item.results || item.result_images || item.outputs || []
const firstImage = Array.isArray(images) && images.length ? images[0] : null
const nestedUrl =
firstImage &&
(typeof firstImage === 'string'
? firstImage
: firstImage.url ||
firstImage.image_url ||
firstImage.imageUrl ||
firstImage.result_url ||
firstImage.file_url ||
firstImage.path ||
'')
const nestedImageId =
firstImage &&
typeof firstImage === 'object' &&
(firstImage.image_id || firstImage.imageId || firstImage.id || firstImage.img_id)
const resultUrl = normalizeOssUrl(
item.result_url ||
item.resultUrl ||
item.image_url ||
item.imageUrl ||
item.path ||
item.thumb_url ||
nestedUrl ||
''
)
const imageId = item.image_id || item.imageId || nestedImageId || ''
// 有图且非明确失败时,按成功展示(避免 completed 但字段不全时误判)
if (resultUrl && status !== 2) status = 1
return {
id: item.id || item.task_id || item.taskId,
taskId: item.task_id || item.taskId || '',
imageId: String(imageId || ''),
path: resultUrl,
status,
style: item.style || item.style_name || item.styleName || '',
reason: item.reason || item.message || item.msg || '',
sw_pid: item.pid || item.sw_pid || item.task_id || item.taskId || '',
num: imageId || item.key || item.num || '',
prod_id: item.prod_id || '',
group_count: item.group_count || 1,
createdAt: item.created_at || item.createdAt || item.completed_at || '',
raw: item,
}
})
return { list: mapped, total, page, page_size: pageSize }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+93
View File
@@ -0,0 +1,93 @@
import logo1 from '@/assets/douyin/logo_1.png'
import logo2 from '@/assets/douyin/logo_2.png'
import zuozi from '@/assets/douyin/zuozi.png'
export const douyinConfig = {
routes: {
home: '/douyin',
generating: '/douyin/generating',
preview: '/douyin/preview',
records: '/douyin/records',
success: '/douyin/success',
},
brandName: '速哇定制',
primaryColor: '#5fbf45',
background: '#ffffff',
images: {
logo: logo2,
/** 点云 Logo 段原图 */
logoCloud: logo1,
effectRef: zuozi,
},
upload: {
minWidth: 300,
minHeight: 300,
maxSize: 10 * 1024 * 1024,
},
poll: {
interval: 5000,
/** 软进度上限;效果图返回后会继续冲到 96%,预览前动画到 100% */
softCap: 88,
},
phases: {
LOGO: { key: 'LOGO', copy: '正在准备生成环境' },
SOURCE_GRAY: { key: 'SOURCE_GRAY', copy: '解析照片主体' },
SOURCE_COLOR: { key: 'SOURCE_COLOR', copy: '提取画面特征' },
WAITING: { key: 'WAITING', copy: 'AI 效果生成中' },
RESULT_REVEAL: { key: 'RESULT_REVEAL', copy: '效果图生成完成' },
DONE: { key: 'DONE', copy: '准备预览' },
},
records: {
pagination: {
pageSize: 20,
initialPage: 1,
},
status: {
success: 1,
designing: 0,
failed: 2,
},
messages: {
title: '生成记录',
note: '注:可点击效果图继续下单',
noMore: '没有更多了',
noData: '暂无生成记录',
designing: '生成中...',
designFailed: '生成失败',
imageLoadFailed: '图片加载失败',
loading: '加载中...',
getDataFailed: '获取记录失败',
loadRetry: '请求失败,点击重试',
},
},
messages: {
homeTitle: 'AI 定制专属效果',
homeHint: '上传照片,轻松生成专属效果图',
uploadBtn: '上传并生成',
startGenerate: '开始生成',
reselect: '重新选图',
addPhoto: '添加照片',
effectLabel: '效果示意',
effectTitle: '坐姿手办风',
remainCount: '剩余次数',
uploading: '上传中...',
uploadSuccess: '照片上传成功',
uploadFail: '上传失败',
pleaseUpload: '请先添加照片',
noRemain: '剩余次数不足',
confirmOrder: '确认提交该效果图并下单?',
orderSuccess: '下单成功',
selectQuantity: '请选择下单数量',
orderSubmitted: '本订单号已提交制作,不能继续上传图片或生图',
orderSubmittedBtn: '订单已提交制作',
},
}
export type DouyinPhaseKey = keyof typeof douyinConfig.phases
+22 -1
View File
@@ -1,13 +1,34 @@
/** 去掉上游重复/冲突的 CORS 头,避免经代理后浏览器仍报跨域 */
const stripCorsHeaders = (proxyRes: { headers: Record<string, any> }) => {
const keys = [
'access-control-allow-origin',
'access-control-allow-credentials',
'access-control-allow-headers',
'access-control-allow-methods',
'access-control-expose-headers',
'access-control-max-age',
]
keys.forEach((key) => {
delete proxyRes.headers[key]
})
}
export const proxy = {
"/api": {
target: "https://shop.api.suwa3d.com/api/web/",
// target: 'http://127.0.0.1:28499',
// target: 'http://172.16.0.36:28499',
changeOrigin: true,
secure: true,
rewrite: (path: string) => {
console.log("path:", path);
return path.replace(/^\/api/, "");
}
},
configure: (proxyServer: any) => {
proxyServer.on('proxyRes', (proxyRes: any) => {
stripCorsHeaders(proxyRes)
})
},
},
"/prod": {
+64
View File
@@ -0,0 +1,64 @@
import 'nprogress/nprogress.css'
import douyinHome from '@/views/douyin/index.vue'
import douyinGenerating from '@/views/douyin/generating.vue'
import douyinPreview from '@/views/douyin/preview.vue'
import douyinRecords from '@/views/douyin/records.vue'
import douyinSuccess from '@/views/douyin/success.vue'
export const routes = [
{
path: '/douyin',
name: 'douyin',
component: douyinHome,
meta: {
needGuard: true,
title: '抖音定制',
channel: 'douyin',
},
},
{
path: '/douyin/generating',
name: 'douyinGenerating',
component: douyinGenerating,
meta: {
needGuard: true,
title: '生成中',
channel: 'douyin',
},
},
{
path: '/douyin/preview',
name: 'douyinPreview',
component: douyinPreview,
meta: {
needGuard: true,
title: '效果预览',
channel: 'douyin',
},
},
{
path: '/douyin/records',
name: 'douyinRecords',
component: douyinRecords,
meta: {
needGuard: true,
title: '生成记录',
channel: 'douyin',
},
},
{
path: '/douyin/success',
name: 'douyinSuccess',
component: douyinSuccess,
meta: {
needGuard: true,
title: '提交成功',
channel: 'douyin',
},
},
]
export function mergeRoutes(allRoutes: any[]) {
allRoutes.push(...routes)
}
+5 -4
View File
@@ -5,6 +5,7 @@ import 'nprogress/nprogress.css'
// 导入路由组件
// import * as badgeRouter from './badge'
import * as cartoonRouter from './cartoon'
import * as douyinRouter from './douyin'
// import badge from '@/views/badge/index.vue'
import cartoon from '@/views/cartoon/index.vue'
import {useStore} from '@/stores'
@@ -28,6 +29,7 @@ const routes = [
// badgeRouter.mergeRoutes(routes)
cartoonRouter.mergeRoutes(routes)
douyinRouter.mergeRoutes(routes)
// 创建路由实例并传递 `routes` 配置
const router = createRouter({
history: createWebHashHistory(process.env.VUE_APP_PUBLIC_PATH),
@@ -56,7 +58,7 @@ router.beforeEach((_to, _from, next) => {
if (code) {
let url = store.redirectUrl()
if (_to.path !== '/cartoon') {
if (_to.path !== '/cartoon' && !_to.path.includes('douyin')) {
if (url) {
store.setRedirect('')
next(url)
@@ -69,9 +71,8 @@ router.beforeEach((_to, _from, next) => {
}
}
// TODO: 根据API获取用信息
window.document.title = `${config.brand.$t} - ${(_to.meta.title as string || '相册').$t}`
// store.updateTitle(_to.meta.title.$t); // 导航栏标题变化
const pageTitle = (_to.meta.title as string) || '相册'
window.document.title = `${config.brand.$t} - ${pageTitle.$t || pageTitle}`
NProgress.start() // start progress bar
const needGuard = _to.meta.needGuard
if (needGuard) {
+2 -1
View File
@@ -14,7 +14,8 @@ export const REQUEST_TOKEN_KEY = 'Authorization'
const request = axios.create({
// API 请求的默认前缀
baseURL: process.env.VUE_APP_API_BASE_URL,
timeout: 6000, // 请求超时时间
// 上传/生图等接口常超过 6s;过短会导致旧业务也误超时
timeout: 30000,
})
export type RequestError = AxiosError<{
+2 -1
View File
@@ -14,7 +14,8 @@ export const REQUEST_TOKEN_KEY = 'Authorization'
const requestWx = axios.create({
// API 请求的默认前缀
baseURL: process.env.VUE_APP_API_WX_URL,
timeout: 6000, // 请求超时时间
// 与 request.ts 保持一致,避免微信相关接口过早超时
timeout: 30000,
})
export type RequestError = AxiosError<{
+160
View File
@@ -0,0 +1,160 @@
<template>
<header class="dy-nav" :class="[`is-${theme}`, { 'has-border': border }]">
<div class="dy-nav__side dy-nav__left">
<button
v-if="showBack"
class="dy-nav__btn dy-nav__back"
:class="{ 'is-text': backText.length > 1 }"
type="button"
aria-label="返回"
@click="emit('back')"
>
{{ backText }}
</button>
<slot v-else name="left" />
</div>
<div class="dy-nav__title">
<slot>{{ title }}</slot>
</div>
<div class="dy-nav__side dy-nav__right">
<button
v-if="rightText"
class="dy-nav__btn dy-nav__action"
type="button"
@click="emit('right')"
>
{{ rightText }}
</button>
<slot v-else name="right" />
</div>
</header>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
title?: string
/** light:白底深字;dark:黑底浅字(生成中) */
theme?: 'light' | 'dark'
showBack?: boolean
backText?: string
rightText?: string
border?: boolean
}>(), {
title: '',
theme: 'light',
showBack: true,
backText: '‹',
rightText: '',
border: false,
})
const emit = defineEmits<{
(e: 'back'): void
(e: 'right'): void
}>()
</script>
<style scoped lang="scss">
.dy-nav {
position: relative;
z-index: 10;
flex-shrink: 0;
display: grid;
grid-template-columns: minmax(80px, 1fr) auto minmax(80px, 1fr);
align-items: center;
min-height: 48px;
padding: 0 8px;
padding-top: env(safe-area-inset-top);
box-sizing: content-box;
}
.dy-nav.is-light {
background: #ffffff;
color: #1a1a1a;
}
.dy-nav.is-dark {
background: #000000;
color: #f5f5f5;
}
.dy-nav.has-border.is-light {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dy-nav.has-border.is-dark {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.dy-nav__side {
display: flex;
align-items: center;
min-width: 0;
height: 48px;
}
.dy-nav__left {
justify-content: flex-start;
}
.dy-nav__right {
justify-content: flex-end;
}
.dy-nav__title {
text-align: center;
font-size: 17px;
font-weight: 600;
line-height: 48px;
max-width: 46vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
}
.dy-nav__btn {
border: none;
background: transparent;
padding: 0 8px;
height: 48px;
font-size: 14px;
font-weight: 500;
color: #5fbf45;
cursor: pointer;
}
.dy-nav__back {
font-size: 28px;
font-weight: 400;
line-height: 44px;
padding: 0 10px 0 6px;
color: inherit;
white-space: nowrap;
}
.dy-nav__back.is-text {
font-size: 15px;
font-weight: 500;
}
.is-light .dy-nav__back {
color: #222;
}
.is-dark .dy-nav__back {
color: #8fd96f;
}
.dy-nav__action {
font-size: 14px;
font-weight: 600;
color: #5fbf45;
}
.is-dark .dy-nav__action {
color: #8fd96f;
}
</style>
@@ -0,0 +1,423 @@
<template>
<div class="ipc" ref="wrapRef" :class="{ 'is-fading': fading }">
<canvas
ref="webglRef"
class="ipc-webgl"
:class="{ live: canvasLive }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@pointerleave="onPointerUp"
></canvas>
<canvas ref="scaleRef" class="ipc-scale" aria-hidden="true"></canvas>
<img
v-if="useFallback"
class="ipc-fallback"
:src="fallbackSrc"
:style="{ opacity: fallbackOpacity }"
alt=""
>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { createRenderer } from './image-point-cloud/webgl/renderer'
import { createStateMachine } from './image-point-cloud/state'
import { PATHS, PHASE_COPY, POINTER, POINT_CLOUD, VISUAL } from './image-point-cloud/constants'
const props = withDefaults(defineProps<{
imageUrl?: string
maskUrl?: string
resultUrl?: string
pointShape?: string
/** 交叉淡出点云(同页出效果图时) */
fading?: boolean
/** 父级锁定的正方形边长(px),优先于 DOM 测量 */
side?: number
}>(), {
pointShape: 'circle',
fading: false,
side: 0,
})
const emit = defineEmits<{
(e: 'ready', payload: { fromCache: boolean }): void
(e: 'phasechange', payload: { phase: string; tip: string; subTip: string }): void
(e: 'handoff'): void
}>()
const wrapRef = ref<HTMLElement | null>(null)
const webglRef = ref<HTMLCanvasElement | null>(null)
const scaleRef = ref<HTMLCanvasElement | null>(null)
const canvasLive = ref(false)
const useFallback = ref(false)
const fallbackSrc = ref(PATHS.LOGO)
const fallbackOpacity = ref(1)
let destroyed = false
let running = false
let firstFrameSent = false
let renderer: any = null
let sm: any = null
let raf = 0
let lastTs = 0
let cssW = 320
let cssH = 320
let logoFromCache = false
let resultKey = ''
let pointerX = 0
let pointerY = 0
let pointerTargetX = 0
let pointerTargetY = 0
let pointerStrength = 0
let pointerStrengthTarget = 0
function enterFallback() {
useFallback.value = true
canvasLive.value = true
emit('ready', { fromCache: false })
emit('phasechange', { phase: 'LOGO_POINT_CLOUD', tip: '准备中', sub: '正在启动' })
}
function teardown() {
pause()
firstFrameSent = false
if (renderer) {
try { renderer.destroy() } catch (e) {}
renderer = null
}
sm = null
}
function pause() {
running = false
pointerStrengthTarget = 0
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
}
function resume() {
if (running || destroyed) return
if (useFallback.value || !renderer || !sm) return
running = true
lastTs = 0
loop()
}
function setPointerFromEvent(e: PointerEvent, active: boolean) {
if (!cssW || !cssH || !webglRef.value) return
const rect = webglRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
pointerTargetX = (x / cssW) * 2 - 1
pointerTargetY = 1 - (y / cssH) * 2
pointerStrengthTarget = active ? 1 : 0
}
function onPointerDown(e: PointerEvent) {
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
setPointerFromEvent(e, true)
pointerX = pointerTargetX
pointerY = pointerTargetY
}
function onPointerMove(e: PointerEvent) {
if (e.buttons || pointerStrengthTarget > 0) setPointerFromEvent(e, true)
}
function onPointerUp() {
pointerStrengthTarget = 0
}
function onPhase(phase: string) {
const copy = (PHASE_COPY as any)[phase] || { tip: '生成中', sub: '请稍候' }
emit('phasechange', { phase, tip: copy.tip, subTip: copy.sub })
}
function prepareSubjectCloud(url: string, maskUrlOverride?: string) {
const maskUrl = maskUrlOverride != null ? maskUrlOverride : props.maskUrl
if (!url || !maskUrl || !renderer || !sm) return
let settled = false
const timer = setTimeout(() => {
if (destroyed || !sm || settled) return
sm.setSubjectFailed()
}, 62000)
renderer.textures
.prepareSubject(url, { maskUrl })
.then((entry: any) => {
if (destroyed || !sm) {
clearTimeout(timer)
return
}
settled = true
clearTimeout(timer)
if (entry && entry.subjectCloudBuffer) sm.setSubjectReady(true)
else sm.setSubjectFailed()
})
.catch(() => {
settled = true
clearTimeout(timer)
if (sm) sm.setSubjectFailed()
})
}
function preloadSource(url: string) {
if (!url || !renderer) return
renderer.textures
.load(url, { removeBackground: true })
.then(() => {
if (destroyed || !sm) return
sm.setSourceReady(true)
if (props.maskUrl) prepareSubjectCloud(url, props.maskUrl)
})
.catch((err: any) => {
console.error('[ImagePointCloud] source preload fail', err)
})
}
function preloadResult(url: string) {
if (!url || !renderer || !sm) return
resultKey = url
sm.setResultPreloadStatus('loading')
renderer.textures
.load(url, { removeBackground: true })
.then((entry: any) => {
if (destroyed || !sm) return
renderer.setResultTexture(entry)
sm.setResultPreloadStatus('ready')
})
.catch((err: any) => {
console.error('[ImagePointCloud] result preload fail', err)
if (sm) sm.setResultPreloadStatus('error')
})
}
function loop() {
if (!running || destroyed) return
const step = () => {
if (!running || destroyed) return
const now = Date.now()
const dt = lastTs ? Math.min(64, now - lastTs) : VISUAL.FRAME_MS
lastTs = now
const uniforms = sm.tick(dt)
const targetStr = pointerStrengthTarget
const curStr = pointerStrength
const baseK = targetStr > curStr ? POINTER.STRENGTH_ATTACK : POINTER.STRENGTH_RELEASE
const kStr = 1 - Math.pow(1 - baseK, dt / 16.67)
pointerStrength = curStr + (targetStr - curStr) * kStr
if (pointerStrength < 0.0015) pointerStrength = 0
const kPos = 1 - Math.pow(1 - POINTER.POS_LERP, dt / 16.67)
pointerX = pointerX + (pointerTargetX - pointerX) * kPos
pointerY = pointerY + (pointerTargetY - pointerY) * kPos
uniforms.pointerX = pointerX
uniforms.pointerY = pointerY
uniforms.pointerStrength = pointerStrength
uniforms.pointerRadius = POINTER.RADIUS
uniforms.pointerInflate = POINTER.INFLATE
uniforms.pointerAspect = cssW / Math.max(cssH, 1)
const shapeMode = (props.pointShape || POINT_CLOUD.DEFAULT_SHAPE).toString().toLowerCase()
if (shapeMode === 'square') uniforms.pointShape = 1
else if (shapeMode === 'circle') uniforms.pointShape = 0
else uniforms.pointShape = 0.5 + 0.5 * Math.sin((uniforms.time || 0) * 1.15)
if (renderer) renderer.draw(uniforms)
if (!firstFrameSent) {
firstFrameSent = true
canvasLive.value = true
emit('ready', { fromCache: logoFromCache })
}
if (sm.isFinished()) {
pause()
return
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
}
/** 优先用父级传入的 side,否则测容器,强制正方形 */
function resolveSquareSide(wrap: HTMLElement) {
if (props.side && props.side > 0) {
return Math.max(120, Math.round(props.side))
}
const rect = wrap.getBoundingClientRect()
const pw = rect.width || wrap.clientWidth || wrap.parentElement?.clientWidth || 280
const ph = rect.height || wrap.clientHeight || wrap.parentElement?.clientHeight || 280
return Math.max(120, Math.round(Math.min(pw, ph)))
}
function applyCanvasSquare(canvas: HTMLCanvasElement, side: number) {
canvas.style.width = `${side}px`
canvas.style.height = `${side}px`
canvas.style.maxWidth = `${side}px`
canvas.style.maxHeight = `${side}px`
canvas.style.display = 'block'
}
function init() {
const canvas = webglRef.value
const scaleCanvas = scaleRef.value
const wrap = wrapRef.value
if (!canvas || !wrap) {
enterFallback()
return
}
const side = resolveSquareSide(wrap)
cssW = side
cssH = side
applyCanvasSquare(canvas, side)
const dpr = Math.min(window.devicePixelRatio || 1, 2)
try {
renderer = createRenderer(canvas, cssW, cssH, dpr, scaleCanvas)
} catch (e) {
console.error('[ImagePointCloud] webgl init fail', e)
enterFallback()
return
}
if (!renderer) {
enterFallback()
return
}
sm = createStateMachine({
onPhase,
onSwitchToSource: () => {
const entry = renderer.textures.get(props.imageUrl)
if (entry && entry.pointCloudBuffer) renderer.transitionToCloud(entry)
},
onSwitchToSubject: () => {
const src = props.imageUrl
const entry = renderer.textures.getSubject(src) || renderer.textures.get(src)
if (entry && entry.subjectCloudBuffer) {
renderer.transitionToCloud({
pointCloudBuffer: entry.subjectCloudBuffer,
aspect: entry.aspect,
})
}
},
onSwitchToResult: () => {
const entry = renderer.textures.get(resultKey || props.resultUrl)
if (entry && entry.pointCloudBuffer) {
renderer.transitionToCloud(entry)
renderer.setResultTexture(entry)
}
},
onHandoff: () => emit('handoff'),
})
renderer.textures
.load(PATHS.LOGO, { removeBackground: true })
.then((entry: any) => {
if (destroyed) return
renderer.setMainCloud(entry)
logoFromCache = !!(entry && entry.pointCloudBuffer)
sm.start()
resume()
if (props.imageUrl) preloadSource(props.imageUrl)
if (props.resultUrl) preloadResult(props.resultUrl)
if (props.maskUrl && props.imageUrl) prepareSubjectCloud(props.imageUrl, props.maskUrl)
})
.catch((err: any) => {
console.error('[ImagePointCloud] logo load fail', err)
enterFallback()
})
}
watch(() => props.imageUrl, (url) => {
if (url && renderer) preloadSource(url)
})
watch(() => props.maskUrl, (url) => {
if (url && props.imageUrl && renderer) prepareSubjectCloud(props.imageUrl, url)
})
watch(() => props.resultUrl, (url) => {
if (url && renderer) preloadResult(url)
})
onMounted(() => {
destroyed = false
const tryInit = (attempt = 0) => {
if (destroyed) return
// 有父级 side 时无需等待 DOM 测量
if (props.side && props.side > 0) {
init()
return
}
const wrap = wrapRef.value
const w = wrap?.getBoundingClientRect().width || 0
const h = wrap?.getBoundingClientRect().height || 0
if ((w < 8 || h < 8) && attempt < 20) {
requestAnimationFrame(() => tryInit(attempt + 1))
return
}
init()
}
requestAnimationFrame(() => tryInit())
})
onUnmounted(() => {
destroyed = true
teardown()
})
</script>
<style scoped lang="scss">
.ipc {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #000000;
touch-action: none;
border-radius: inherit;
display: flex;
align-items: center;
justify-content: center;
}
.ipc-webgl {
display: block;
flex-shrink: 0;
opacity: 0;
transition: opacity 1.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.ipc-webgl.live {
opacity: 1;
}
.ipc.is-fading .ipc-webgl.live {
opacity: 0;
}
.ipc-scale {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
left: -9999px;
}
.ipc-fallback {
position: absolute;
inset: 12%;
width: 76%;
height: 76%;
object-fit: contain;
margin: auto;
}
</style>
@@ -0,0 +1,172 @@
<template>
<div class="pcw">
<div class="pcw-stage">
<img
v-if="displayUrl"
:src="displayUrl"
class="pcw-img"
:class="imgClass"
alt=""
>
<div v-else class="pcw-placeholder"></div>
<div class="pcw-particles" aria-hidden="true">
<span v-for="n in 18" :key="n" class="dot" :style="dotStyle(n)"></span>
</div>
</div>
<div class="pcw-progress">
<div class="pcw-bar">
<div class="pcw-bar-inner" :style="{ width: `${progress}%` }"></div>
</div>
<div class="pcw-meta">
<span>{{ copy }}</span>
<span>{{ Math.floor(progress) }}%</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { douyinConfig } from '@/config/douyin'
import logo2 from '@/assets/douyin/logo_2.png'
const props = defineProps<{
phase: string
progress: number
sourceUrl?: string
resultUrl?: string
}>()
const copy = computed(() => {
const found = Object.values(douyinConfig.phases).find(p => p.key === props.phase)
return found?.copy || '生成中...'
})
const displayUrl = computed(() => {
if (props.phase === 'LOGO') return logo2
if (props.phase === 'RESULT_REVEAL' || props.phase === 'DONE') {
return props.resultUrl || props.sourceUrl || logo2
}
return props.sourceUrl || logo2
})
const imgClass = computed(() => {
if (props.phase === 'SOURCE_GRAY') return 'is-gray'
if (props.phase === 'SOURCE_COLOR' || props.phase === 'WAITING') return 'is-color'
if (props.phase === 'RESULT_REVEAL' || props.phase === 'DONE') return 'is-reveal'
return 'is-logo'
})
function dotStyle(n: number) {
const angle = (n / 18) * Math.PI * 2
const r = 38 + (n % 5) * 6
const x = 50 + Math.cos(angle) * r * 0.35
const y = 50 + Math.sin(angle) * r * 0.35
const delay = (n % 9) * 0.12
return {
left: `${x}%`,
top: `${y}%`,
animationDelay: `${delay}s`,
}
}
</script>
<style scoped lang="scss">
.pcw {
width: 100%;
}
.pcw-stage {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 16px;
overflow: hidden;
background: #0c0d10;
display: flex;
align-items: center;
justify-content: center;
}
.pcw-img {
max-width: 78%;
max-height: 78%;
object-fit: contain;
transition: filter 0.8s ease, opacity 0.8s ease, transform 0.8s ease;
}
.pcw-img.is-logo {
opacity: 0.95;
transform: scale(0.92);
}
.pcw-img.is-gray {
filter: grayscale(1) contrast(1.05);
opacity: 0.85;
}
.pcw-img.is-color {
filter: none;
opacity: 1;
}
.pcw-img.is-reveal {
filter: none;
opacity: 1;
transform: scale(1);
}
.pcw-placeholder {
width: 40%;
height: 40%;
border-radius: 50%;
background: rgba(95, 191, 69, 0.2);
}
.pcw-particles {
pointer-events: none;
position: absolute;
inset: 0;
}
.dot {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(95, 191, 69, 0.85);
transform: translate(-50%, -50%);
animation: floatDot 2.4s ease-in-out infinite;
}
@keyframes floatDot {
0%, 100% { opacity: 0.2; transform: translate(-50%, -50%) scale(0.8); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.3); }
}
.pcw-progress {
margin-top: 16px;
}
.pcw-bar {
height: 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.pcw-bar-inner {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
transition: width 0.35s ease;
}
.pcw-meta {
margin-top: 8px;
display: flex;
justify-content: space-between;
font-size: 13px;
color: #666;
}
</style>
@@ -0,0 +1,208 @@
/** Image point cloud — shared constants */
import logo1 from '@/assets/douyin/logo_1.png'
const PHASE = {
INIT: 'INIT',
LOGO_POINT_CLOUD: 'LOGO_POINT_CLOUD',
IMAGE_GRAY_POINT_CLOUD: 'IMAGE_GRAY_POINT_CLOUD',
IMAGE_COLOR_TRANSITION: 'IMAGE_COLOR_TRANSITION',
IMAGE_COLOR_POINT_CLOUD: 'IMAGE_COLOR_POINT_CLOUD',
/** 人物检测(切 Buff + 开始抠背景) */
SUBJECT_DETECTING: 'SUBJECT_DETECTING',
/** 人物轮廓显现(灰色) */
SUBJECT_OUTLINE: 'SUBJECT_OUTLINE',
/** 灰色人物抠图点云 5s */
SUBJECT_CLOUD_GRAY: 'SUBJECT_CLOUD_GRAY',
/** 人物灰 → 彩 */
SUBJECT_CLOUD_COLORIZE: 'SUBJECT_CLOUD_COLORIZE',
/** 彩色人物抠图,等待效果图 */
SUBJECT_CLOUD_COLOR: 'SUBJECT_CLOUD_COLOR',
/** @deprecated 兼容旧名,等同 SUBJECT_CLOUD_COLOR */
SUBJECT_POINT_CLOUD: 'SUBJECT_CLOUD_COLOR',
RESULT_PRELOADING: 'RESULT_PRELOADING',
RESULT_TRANSITION: 'RESULT_TRANSITION',
// 效果图灰色点云 ~1s
RESULT_CLOUD_GRAY: 'RESULT_CLOUD_GRAY',
/** 效果图灰 → 彩过渡 */
RESULT_CLOUD_COLORIZE: 'RESULT_CLOUD_COLORIZE',
/** 效果图彩色点云 ~3s */
RESULT_CLOUD_COLOR: 'RESULT_CLOUD_COLOR',
/** 点云 → 完整效果图 */
RESULT_IMAGE_REVEAL: 'RESULT_IMAGE_REVEAL',
/** 完整效果图停留 ~2s */
RESULT_PREVIEW: 'RESULT_PREVIEW',
NAVIGATING: 'NAVIGATING',
}
const TIMING = {
LOGO_MS: 10000,
IMAGE_GRAY_MS: 5000,
COLOR_TRANSITION_MS: 1500,
TEX_CROSSFADE_MS: 1000,
/** 原图彩色点云停留后再进人物抠图 */
IMAGE_COLOR_MS: 10000,
/** 人物检测相位最长等待(Buffer 已就绪时很快进入轮廓) */
SUBJECT_DETECT_MS: 900,
/** 检测 CPU 预算;超时则放弃人物段 */
SUBJECT_DETECT_BUDGET_MS: 1200,
SUBJECT_OUTLINE_MS: 1600,
/** 灰色人物抠图停留 5s 后再上色 */
SUBJECT_GRAY_MS: 5000,
/** 人物点云最短停留(彩色段);结果 ready 可提前打断 */
SUBJECT_HOLD_MS: 1200,
RESULT_TRANSITION_MS: 1200,
/** 效果图灰色点云 */
RESULT_CLOUD_GRAY_MS: 1000,
/** 效果图彩色点云 */
RESULT_CLOUD_COLOR_MS: 3000,
/** 点云淡出、完整图淡入(交叉淡化) */
RESULT_IMAGE_FADE_MS: 1600,
/** 完整图短暂停留后进入同页预览态 */
RESULT_PREVIEW_MS: 360,
NAVIGATING_MS: 200,
ASSEMBLY_MS: 900,
}
/** 与阶段绑定的主/副文案(简单直接,不出现「点云」) */
const PHASE_COPY = {
INIT: {
tip: '准备中',
sub: '马上开始',
},
LOGO_POINT_CLOUD: {
tip: '准备中',
sub: '正在启动',
},
IMAGE_GRAY_POINT_CLOUD: {
tip: '分析照片',
sub: '请稍候',
},
IMAGE_COLOR_TRANSITION: {
tip: '处理照片',
sub: '请稍候',
},
IMAGE_COLOR_POINT_CLOUD: {
tip: '处理照片',
sub: '请稍候',
},
SUBJECT_DETECTING: {
tip: '识别人物',
sub: '请稍候',
},
SUBJECT_OUTLINE: {
tip: '勾勒轮廓',
sub: '请稍候',
},
SUBJECT_CLOUD_GRAY: {
tip: '处理人物',
sub: '请稍候',
},
SUBJECT_CLOUD_COLORIZE: {
tip: '人物上色',
sub: '请稍候',
},
SUBJECT_CLOUD_COLOR: {
tip: '生成中',
sub: '请稍候',
},
SUBJECT_POINT_CLOUD: {
tip: '生成中',
sub: '请稍候',
},
RESULT_PRELOADING: {
tip: '生成中',
sub: '请稍候',
},
RESULT_TRANSITION: {
tip: '合成效果',
sub: '请稍候',
},
RESULT_CLOUD_GRAY: {
tip: '完善效果',
sub: '请稍候',
},
RESULT_CLOUD_COLORIZE: {
tip: '完善效果',
sub: '请稍候',
},
RESULT_CLOUD_COLOR: {
tip: '完善效果',
sub: '请稍候',
},
RESULT_IMAGE_REVEAL: {
tip: '已完成',
sub: '正在展示效果图',
},
RESULT_PREVIEW: {
tip: '已完成',
sub: '即将进入预览',
},
NAVIGATING: {
tip: '请稍候',
sub: '正在打开预览',
},
}
const PATHS = {
/** Logo 点云原图 */
LOGO: logo1,
}
const POINT_CLOUD = {
HIGH: 12800,
MEDIUM: 9200,
LOW: 6200,
/** 人物点云 */
SUBJECT_HIGH: 12000,
SUBJECT_MEDIUM: 9000,
SUBJECT_LOW: 6000,
SAMPLE_HIGH: 448,
SAMPLE_MEDIUM: 384,
SAMPLE_LOW: 320,
MASK_HIGH: 288,
MASK_MEDIUM: 256,
MASK_LOW: 160,
/** 略小粒子,减少重叠杂乱,仍保留远近差 */
MIN_SIZE: 1.45,
MAX_SIZE: 3.25,
/** 采样抖动加大,粒子更散开 */
POSITION_JITTER: 0.011,
OPACITY: 0.93,
BG_THRESHOLD: 0.12,
/** x,y,r,g,b,size,alpha,seed,subjectWeight */
STRIDE_FLOATS: 9,
/**
* 粒子形状:circle | square | auto
* auto = 随时间在圆/方之间缓动切换
*/
DEFAULT_SHAPE: 'circle',
/** Logo 缓存版本:stride 变更时失效旧缓存 */
CACHE_VERSION: 14,
}
const VISUAL = {
MAX_TEX_SIZE: 512,
LOW_TEX_SIZE: 384,
FRAME_MS: 33,
/** 与等待页背景 #000 一致,避免画布出现灰框 */
STAGE_BG: [0, 0, 0, 1],
}
/** 触点大圆膨胀(clip space,无空洞) */
const POINTER = {
RADIUS: 0.38,
STRENGTH_ATTACK: 0.32,
STRENGTH_RELEASE: 0.08,
POS_LERP: 0.45,
INFLATE: 0.14,
}
export {
PHASE,
TIMING,
PHASE_COPY,
PATHS,
POINT_CLOUD,
VISUAL,
POINTER,
}
@@ -0,0 +1,123 @@
import { PATHS, POINT_CLOUD } from './constants'
import { createPointCloudSampler } from './point-cloud-sampler'
/**
* 预采样 Logo 点云(CPU Float32Array),生成页可立刻上传 GPU。
*/
let cached = null
let inflight = null
function pickTier() {
let maxPoints = POINT_CLOUD.MEDIUM
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
try {
const dpr = window.devicePixelRatio || 1
const cores = navigator.hardwareConcurrency || 4
if (dpr >= 2.5 && cores >= 8) {
maxPoints = POINT_CLOUD.HIGH
sampleSide = POINT_CLOUD.SAMPLE_HIGH
} else if (cores <= 4) {
maxPoints = POINT_CLOUD.LOW
sampleSide = POINT_CLOUD.SAMPLE_LOW
}
} catch (e) {}
return { maxPoints, sampleSide }
}
export function getCachedLogoCloud() {
if (!cached) return null
if (cached.cacheVersion !== POINT_CLOUD.CACHE_VERSION) {
cached = null
return null
}
return cached
}
export function isLogoReady() {
const c = getCachedLogoCloud()
return !!(c && c.cloud && c.cloud.data)
}
export function putCachedLogoCloud(entry) {
if (!entry || !entry.cloud) return
cached = {
...entry,
cacheVersion: POINT_CLOUD.CACHE_VERSION,
src: PATHS.LOGO,
}
}
/**
* @param {{ canvas?: HTMLCanvasElement, scaleCanvas?: HTMLCanvasElement }} nodes
*/
export function preloadLogoCloud(nodes = {}) {
if (isLogoReady()) return Promise.resolve(cached)
if (inflight) return inflight
const scaleCanvas = nodes.scaleCanvas || nodes.canvas || document.createElement('canvas')
// 与等待页正方形舞台一致,避免预采样比例与展示框不一致导致拉伸
const side = 512
const vp = { w: side, h: side }
const tier = pickTier()
let scaleCtx = null
try {
scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true })
} catch (e) {
scaleCtx = scaleCanvas.getContext('2d')
}
const sampler = createPointCloudSampler(scaleCanvas, vp.w, vp.h, scaleCtx)
inflight = new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
try {
const w = img.width || 0
const h = img.height || 0
const entry = {
image: img,
path: PATHS.LOGO,
width: w,
height: h,
aspect: w / Math.max(h, 1),
}
const cloud = sampler.sample(entry, {
maxPoints: tier.maxPoints,
maxSide: tier.sampleSide,
removeBackground: true,
})
putCachedLogoCloud({
src: PATHS.LOGO,
width: w,
height: h,
aspect: entry.aspect,
maxPoints: cloud.maxPoints,
cloud: {
data: cloud.data.slice(0),
count: cloud.count,
maxPoints: cloud.maxPoints,
aspect: cloud.aspect,
},
})
resolve(cached)
} catch (err) {
reject(err)
} finally {
inflight = null
}
}
img.onerror = () => {
inflight = null
reject(new Error('logo preload fail'))
}
img.src = PATHS.LOGO
})
return inflight
}
export function clearLogoCloudCache() {
cached = null
inflight = null
}
@@ -0,0 +1,267 @@
/**
* 将 Mask 图解码为软 Mask(Uint8Array 0~255)
* 白=主体,黑=背景;与原图必须同逻辑尺寸(等比缩放)
*/
function clamp01(v) {
return Math.max(0, Math.min(1, v))
}
function luminance(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b
}
function smoothStep(edge0, edge1, value) {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
return t * t * (3 - 2 * t)
}
/**
* @param {{ width: number, height: number }} sourceSize
* @param {{ width: number, height: number }} maskSize
* @param {number} maxSide
*/
function calculateTargetSize(width, height, maxSide) {
const scale = Math.min(1, maxSide / Math.max(width, height, 1))
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
scale: scale,
}
}
/**
* 从已绘制到 canvas 的 Mask 像素构建 SubjectMask
* @returns {{ width: number, height: number, data: Uint8Array } | null}
*/
function buildMaskFromPixels(pixels, w, h) {
if (!pixels || !w || !h) return null
const data = new Uint8Array(w * h)
let fg = 0
for (let i = 0, p = 0; i < w * h; i++, p += 4) {
const v = luminance(pixels[p], pixels[p + 1], pixels[p + 2])
// 0~255 软权重;全黑附近保持 0
const weight = smoothStep(32, 220, v)
const u8 = Math.round(weight * 255)
data[i] = u8
if (u8 > 40) fg += 1
}
if (fg < 24 || fg / (w * h) < 0.008) {
return null
}
return { width: w, height: h, data: data }
}
/**
* 将 maskImage 等比缩放到 target,读像素
* @returns {{ width: number, height: number, data: Uint8Array } | null}
*/
function decodeMaskImage(maskImage, ctx, canvas, targetW, targetH) {
if (!maskImage || !ctx || !canvas || !targetW || !targetH) return null
canvas.width = targetW
canvas.height = targetH
ctx.clearRect(0, 0, targetW, targetH)
try {
ctx.drawImage(maskImage, 0, 0, targetW, targetH)
} catch (e) {
return null
}
let pixels
try {
pixels = ctx.getImageData(0, 0, targetW, targetH).data
} catch (e) {
return null
}
return buildMaskFromPixels(pixels, targetW, targetH)
}
/**
* 校验并得到统一采样尺寸;尺寸不一致时按原图比例缩放到同一 target
*/
function resolveAlignedSize(sourceW, sourceH, maskW, maskH, maxSide) {
if (!sourceW || !sourceH || !maskW || !maskH) return null
const same = sourceW === maskW && sourceH === maskH
const target = calculateTargetSize(sourceW, sourceH, maxSide)
if (!same) {
// 业务约定应同尺寸;不一致时仍按原图比例统一缩放双方
const maskTarget = calculateTargetSize(maskW, maskH, maxSide)
// 若宽高比偏差过大则不可靠
const a0 = sourceW / sourceH
const a1 = maskW / maskH
if (Math.abs(a0 - a1) / Math.max(a0, a1) > 0.08) {
return null
}
// 使用原图 target,Mask 强制画到同一尺寸
return {
width: target.width,
height: target.height,
sameSize: false,
maskNative: maskTarget,
}
}
return {
width: target.width,
height: target.height,
sameSize: true,
}
}
/**
* 从效果图自身 flood-fill 抠主体 Mask(手办类白底/纯色底)
* 白=主体,黑=背景,与 API Mask 约定一致
* @returns {{ width: number, height: number, data: Uint8Array } | null}
*/
function buildFloodFillSubjectMask(pixels, w, h, options) {
options = options || {}
if (!pixels || !w || !h) return null
const colorTol = options.colorTol != null ? options.colorTol : 38
const lumTol = options.lumTol != null ? options.lumTol : 28
const data = new Uint8ClampedArray(pixels)
const bg = new Uint8Array(w * h) // 1 = background
// 四角采样背景色
const corners = [
[0, 0],
[w - 1, 0],
[0, h - 1],
[w - 1, h - 1],
]
let bgR = 0
let bgG = 0
let bgB = 0
corners.forEach(function (p) {
const i = (p[1] * w + p[0]) * 4
bgR += data[i]
bgG += data[i + 1]
bgB += data[i + 2]
})
bgR /= 4
bgG /= 4
bgB /= 4
const bgLum = luminance(bgR, bgG, bgB)
function isBgPixel(x, y) {
const i = (y * w + x) * 4
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const a = data[i + 3]
if (a < 18) return true
const dr = r - bgR
const dg = g - bgG
const db = b - bgB
const dist = Math.sqrt(dr * dr + dg * dg + db * db)
if (dist <= colorTol) return true
// 接近背景亮度的浅色也当背景(手办白底常见)
const lum = luminance(r, g, b)
if (Math.abs(lum - bgLum) <= lumTol && dist <= colorTol * 1.85) return true
// 近白且背景也偏亮
if (bgLum > 200 && lum > 235 && dist <= colorTol * 2.2) return true
return false
}
// 从四边 flood-fill 连通背景
const queue = new Int32Array(w * h)
let qh = 0
let qt = 0
function tryPush(x, y) {
if (x < 0 || y < 0 || x >= w || y >= h) return
const idx = y * w + x
if (bg[idx]) return
if (!isBgPixel(x, y)) return
bg[idx] = 1
queue[qt++] = idx
}
for (let x = 0; x < w; x++) {
tryPush(x, 0)
tryPush(x, h - 1)
}
for (let y = 0; y < h; y++) {
tryPush(0, y)
tryPush(w - 1, y)
}
while (qh < qt) {
const idx = queue[qh++]
const x = idx % w
const y = (idx / w) | 0
tryPush(x - 1, y)
tryPush(x + 1, y)
tryPush(x, y - 1)
tryPush(x, y + 1)
}
// 生成软 Mask:主体=255,背景=0,边缘略软化
const out = new Uint8Array(w * h)
let fg = 0
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x
if (bg[idx]) {
out[idx] = 0
continue
}
// 邻域背景占比 → 边缘软化
let border = 0
let n = 0
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx
const ny = y + dy
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
n++
if (bg[ny * w + nx]) border++
}
}
const edge = n > 0 ? border / n : 0
const weight = Math.round(255 * (1 - edge * 0.55))
out[idx] = Math.max(48, weight)
fg++
}
}
if (fg < 24 || fg / (w * h) < 0.008) return null
// 抠得太狠(几乎全是背景)不可用
if (fg / (w * h) > 0.97) return null
return { width: w, height: h, data: out }
}
/**
* 从图片生成 flood-fill 主体 Mask
*/
function buildSubjectMaskFromImage(image, ctx, canvas, maxSide) {
if (!image || !ctx || !canvas) return null
const srcW = image.width || 0
const srcH = image.height || 0
if (!srcW || !srcH) return null
const target = calculateTargetSize(srcW, srcH, maxSide || 256)
canvas.width = target.width
canvas.height = target.height
ctx.clearRect(0, 0, target.width, target.height)
try {
ctx.drawImage(image, 0, 0, target.width, target.height)
} catch (e) {
return null
}
let pixels
try {
pixels = ctx.getImageData(0, 0, target.width, target.height).data
} catch (e) {
return null
}
return buildFloodFillSubjectMask(pixels, target.width, target.height)
}
export {
clamp01,
luminance,
smoothStep,
calculateTargetSize,
buildMaskFromPixels,
decodeMaskImage,
resolveAlignedSize,
buildFloodFillSubjectMask,
buildSubjectMaskFromImage,
}
@@ -0,0 +1,274 @@
import { POINT_CLOUD } from './constants'
import { sampleMaskValue } from './subject-outline'
/**
* Preload-only sampler: image → Float32Array particle buffer.
* Never called from the render loop.
*
* Layout (9 floats / particle):
* x, y, r, g, b, size, alpha, seed, subjectWeight
*/
function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
let ctx = sharedCtx || null
if (!ctx && scaleCanvas) {
try {
ctx = scaleCanvas.getContext('2d', { willReadFrequently: true })
} catch (e) {
try {
ctx = scaleCanvas.getContext('2d')
} catch (e2) {
ctx = null
}
}
}
function hash(x, y) {
const v = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
return v - Math.floor(v)
}
function luminance(r, g, b) {
return (r * 0.299 + g * 0.587 + b * 0.114) / 255
}
function sample(entry, options) {
options = options || {}
if (!ctx || !entry || !entry.image) {
throw new Error('point cloud sampler unavailable')
}
const maxPoints = options.maxPoints || POINT_CLOUD.MEDIUM
const removeBackground = options.removeBackground !== false
const maxSide = options.maxSide || POINT_CLOUD.SAMPLE_MEDIUM
const subjectMask = options.subjectMask || null
const outlineMask = options.outlineMask || null
const maskMode = options.maskMode || 'full' // full | outline | subject
const forceSampleSize = options.forceSampleSize || null
const image = entry.image
if (!image || !image.width || !image.height) {
throw new Error('image not ready for sampling')
}
const srcW = image.width
const srcH = image.height
let w
let h
if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) {
w = Math.max(1, Math.round(forceSampleSize.width))
h = Math.max(1, Math.round(forceSampleSize.height))
} else {
const scale = Math.min(1, maxSide / Math.max(srcW, srcH))
w = Math.max(1, Math.round(srcW * scale))
h = Math.max(1, Math.round(srcH * scale))
}
scaleCanvas.width = w
scaleCanvas.height = h
ctx.clearRect(0, 0, w, h)
ctx.drawImage(image, 0, 0, w, h)
let pixels
try {
pixels = ctx.getImageData(0, 0, w, h).data
} catch (err) {
throw new Error('getImageData unavailable: ' + (err && err.message))
}
const viewportAspect = viewportW / Math.max(viewportH, 1)
const imageAspect = w / Math.max(h, 1)
let scaleX = 0.98
let scaleY = 0.98
if (imageAspect > viewportAspect) {
scaleY = (scaleX * viewportAspect) / imageAspect
} else {
scaleX = (scaleY * imageAspect) / viewportAspect
}
const useSubjectMask = !!(subjectMask && subjectMask.data)
const stride = Math.max(
1,
Math.ceil(
Math.sqrt(
(w * h) / (maxPoints * (useSubjectMask ? 2.1 : 1.35))
)
)
)
const corners = [
[0, 0],
[w - 1, 0],
[0, h - 1],
[w - 1, h - 1],
]
let bgR = 0
let bgG = 0
let bgB = 0
corners.forEach(function (p) {
const i = (p[1] * w + p[0]) * 4
bgR += pixels[i]
bgG += pixels[i + 1]
bgB += pixels[i + 2]
})
bgR /= 4
bgG /= 4
bgB /= 4
const candidates = []
const minSize = POINT_CLOUD.MIN_SIZE
const maxSize = POINT_CLOUD.MAX_SIZE
const jitterAmp = POINT_CLOUD.POSITION_JITTER
for (let y = 0; y < h; y += stride) {
for (let x = 0; x < w; x += stride) {
const i = (y * w + x) * 4
const r = pixels[i]
const g = pixels[i + 1]
const b = pixels[i + 2]
const a = pixels[i + 3] / 255
if (a < 0.05) continue
const lum = luminance(r, g, b)
let presence = a
let subjectWeight = 1
if (useSubjectMask) {
const maskVal = sampleMaskValue(
subjectMask,
outlineMask,
x,
y,
w,
h,
maskMode === 'outline' ? 'outline' : 'subject'
)
// API Mask:灰度 < ~32/255 丢弃;soft weight 来自 smoothStep(32,220)
if (maskVal < 0.05) continue
const density = 0.75 + maskVal * 0.25
if (hash(x + 3, y + 11) > density) continue
presence = Math.max(0.35, maskVal) * a
subjectWeight = Math.max(0, Math.min(1, maskVal))
} else if (removeBackground) {
const dr = r - bgR
const dg = g - bgG
const db = b - bgB
const distance = Math.sqrt(dr * dr + dg * dg + db * db) / 441.67
const notBackground = Math.max(0, Math.min(1, (distance - 0.025) / 0.09))
const chroma = Math.abs(r - g) + Math.abs(g - b) + Math.abs(r - b)
const nearBlack = lum < 0.07 && chroma < 16
if (nearBlack) continue
const darkKeep = Math.max(0, Math.min(1, (0.62 - lum) / 0.45))
const softKeep = Math.max(0, Math.min(1, (distance - 0.015) / 0.07))
presence = Math.max(notBackground, darkKeep * 0.5, softKeep * 0.4) * a
subjectWeight = 1
}
if (presence < 0.045) continue
const edge = 1 - presence
let probability = 0.28 + Math.pow(presence, 0.6) * 0.55 - edge * 0.08
if (useSubjectMask) {
// 人物区几乎全保留,形成可读轮廓
probability = Math.min(0.99, 0.55 + subjectWeight * 0.45)
}
const seed = hash(x, y)
if (seed > Math.min(0.95, probability)) continue
const uvX = x / Math.max(w - 1, 1)
const uvY = y / Math.max(h - 1, 1)
// 人物采样几乎不抖,轮廓更贴合
const edgeJitter = useSubjectMask
? 0.25 + (1 - subjectWeight) * 0.5
: 1.35 + edge * 2.2
const jx = (hash(x + 17, y + 31) - 0.5) * jitterAmp * edgeJitter
const jy = (hash(x + 41, y + 7) - 0.5) * jitterAmp * edgeJitter
const px = (uvX - 0.5) * 2 * scaleX + jx
const py = (0.5 - uvY) * 2 * scaleY + jy
const band = seed < 0.28 ? 0.0 : seed < 0.62 ? 0.45 : 1.0
let depth = Math.max(
0.08,
Math.min(
1,
0.1 + band * 0.42 + Math.pow(lum, 0.65) * 0.28 + seed * 0.18
)
)
let size = minSize + Math.pow(depth, 0.85) * (maxSize - minSize)
const alpha = Math.min(
1,
presence * POINT_CLOUD.OPACITY * (0.82 + depth * 0.18)
)
candidates.push({
x: px,
y: py,
r: r / 255,
g: g / 255,
b: b / 255,
size: size,
alpha: alpha,
seed: seed,
subjectWeight: subjectWeight,
})
}
}
let selected = candidates
if (selected.length > maxPoints) {
const step = selected.length / maxPoints
selected = []
for (let i = 0; i < maxPoints; i++) {
selected.push(candidates[Math.floor(i * step)])
}
}
// 无足够人物点 → 采样失败(由上层 fallback)
if (useSubjectMask && selected.length < Math.min(80, maxPoints * 0.02)) {
throw new Error('subject sample too sparse')
}
const floats = POINT_CLOUD.STRIDE_FLOATS
const data = new Float32Array(maxPoints * floats)
for (let i = 0; i < maxPoints; i++) {
const p = selected[i]
const o = i * floats
if (!p) {
data[o] = 0
data[o + 1] = 0
data[o + 2] = 1
data[o + 3] = 1
data[o + 4] = 1
data[o + 5] = 0
data[o + 6] = 0
data[o + 7] = 0
data[o + 8] = 0
continue
}
data[o] = p.x
data[o + 1] = p.y
data[o + 2] = p.r
data[o + 3] = p.g
data[o + 4] = p.b
data[o + 5] = p.size
data[o + 6] = p.alpha
data[o + 7] = p.seed
data[o + 8] = p.subjectWeight != null ? p.subjectWeight : 1
}
return {
data: data,
count: selected.length,
maxPoints: maxPoints,
aspect: imageAspect,
}
}
return {
sample: sample,
}
}
export {
createPointCloudSampler,
}
@@ -0,0 +1,468 @@
import { PHASE, TIMING } from './constants'
function easeInOut(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
}
function smootherstep(t) {
t = Math.max(0, Math.min(1, t))
return t * t * t * (t * (t * 6 - 15) + 10)
}
function createStateMachine(hooks) {
let phase = PHASE.INIT
let phaseStartedAt = 0
let timeSec = 0
let colorProgress = 0
let opacity = 1
let reveal = 0
let transition = 1
let assembly = 1
let motion = 1
let subjectProgress = 0
let outlineProgress = 0
let sourceReady = false
let subjectReady = false
let subjectFailed = false
let resultPreloadStatus = 'idle'
let colorWaitReached = false
let finished = false
let handoffSent = false
let morphing = false
let morphStartedAt = 0
let subjectEntered = false
function emitPhase(p) {
if (hooks && hooks.onPhase) hooks.onPhase(p)
}
function enter(p) {
if (phase === p) return
phase = p
phaseStartedAt = Date.now()
emitPhase(p)
}
function start() {
finished = false
handoffSent = false
timeSec = 0
colorProgress = 0
opacity = 1
reveal = 0
transition = 1
assembly = 0.55
motion = 1
subjectProgress = 0
outlineProgress = 0
colorWaitReached = false
morphing = false
subjectReady = false
subjectFailed = false
subjectEntered = false
enter(PHASE.LOGO_POINT_CLOUD)
}
function setSourceReady(v) {
sourceReady = !!v
}
function setSubjectReady(v) {
subjectReady = !!v
if (v) subjectFailed = false
else subjectFailed = true
}
function setSubjectFailed() {
subjectFailed = true
subjectReady = false
}
function setResultPreloadStatus(s) {
resultPreloadStatus = s
}
function getUniforms() {
return {
time: timeSec,
colorProgress: colorProgress,
opacity: opacity,
reveal: reveal,
transition: transition,
assembly: assembly,
motion: motion,
subjectProgress: subjectProgress,
outlineProgress: outlineProgress,
}
}
function beginMorph() {
transition = 0
morphing = true
morphStartedAt = Date.now()
}
function tryEnterSubject() {
if (subjectEntered || subjectFailed || !subjectReady) return false
if (phase !== PHASE.IMAGE_COLOR_POINT_CLOUD) return false
// 原图彩色点云需先完整展示
if (Date.now() - phaseStartedAt < TIMING.IMAGE_COLOR_MS) return false
// 结果已好则优先进结果,跳过人物段
if (resultPreloadStatus === 'ready') return false
subjectEntered = true
if (hooks && hooks.onSwitchToSubject) hooks.onSwitchToSubject()
beginMorph()
enter(PHASE.SUBJECT_DETECTING)
return true
}
function tryStartResultTransition() {
if (!colorWaitReached) return false
if (resultPreloadStatus !== 'ready') return false
if (
phase !== PHASE.IMAGE_COLOR_POINT_CLOUD &&
phase !== PHASE.RESULT_PRELOADING &&
phase !== PHASE.SUBJECT_DETECTING &&
phase !== PHASE.SUBJECT_OUTLINE &&
phase !== PHASE.SUBJECT_CLOUD_COLOR &&
phase !== PHASE.SUBJECT_POINT_CLOUD
) {
return false
}
// 效果图阶段清零人物进度,保证 RESULT_* 观感不变
subjectProgress = 0
outlineProgress = 0
reveal = 0
opacity = 1
assembly = 0.4
enter(PHASE.RESULT_TRANSITION)
if (hooks && hooks.onSwitchToResult) hooks.onSwitchToResult()
beginMorph()
return true
}
function tick(dtMs) {
if (finished) return getUniforms()
const dt = Math.min(64, dtMs || 33)
timeSec += dt / 1000
const elapsed = Date.now() - phaseStartedAt
if (morphing) {
const mt = Math.min(
1,
(Date.now() - morphStartedAt) / TIMING.TEX_CROSSFADE_MS
)
transition = smootherstep(mt)
assembly = 0.35 + 0.65 * Math.abs(mt - 0.5) * 2
if (mt >= 1) {
transition = 1
assembly = 1
morphing = false
}
}
if (phase === PHASE.LOGO_POINT_CLOUD) {
colorProgress = 0
opacity = 1
reveal = 0
motion = 1
subjectProgress = 0
outlineProgress = 0
if (!morphing) {
const at = Math.min(1, elapsed / TIMING.ASSEMBLY_MS)
assembly = 0.55 + 0.45 * smootherstep(at)
}
if (elapsed >= TIMING.LOGO_MS && sourceReady) {
if (hooks && hooks.onSwitchToSource) hooks.onSwitchToSource()
beginMorph()
enter(PHASE.IMAGE_GRAY_POINT_CLOUD)
}
return getUniforms()
}
if (phase === PHASE.IMAGE_GRAY_POINT_CLOUD) {
colorProgress = 0
opacity = 1
reveal = 0
motion = 1
subjectProgress = 0
outlineProgress = 0
if (!morphing && elapsed >= TIMING.IMAGE_GRAY_MS) {
enter(PHASE.IMAGE_COLOR_TRANSITION)
}
return getUniforms()
}
if (phase === PHASE.IMAGE_COLOR_TRANSITION) {
const t = Math.min(1, elapsed / TIMING.COLOR_TRANSITION_MS)
colorProgress = easeInOut(t)
opacity = 1
reveal = 0
motion = 1
subjectProgress = 0
outlineProgress = 0
if (!morphing) assembly = 1
if (t >= 1) {
colorProgress = 1
colorWaitReached = true
enter(PHASE.IMAGE_COLOR_POINT_CLOUD)
// 不立刻进人物:先展示原图彩色点云 IMAGE_COLOR_MS
tryStartResultTransition()
}
return getUniforms()
}
if (
phase === PHASE.IMAGE_COLOR_POINT_CLOUD ||
phase === PHASE.RESULT_PRELOADING
) {
colorProgress = 1
opacity = 1
reveal = 0
motion = 1
subjectProgress = 0
outlineProgress = 0
if (!morphing) assembly = 1
if (
resultPreloadStatus === 'loading' &&
phase === PHASE.IMAGE_COLOR_POINT_CLOUD &&
Date.now() - phaseStartedAt >= TIMING.IMAGE_COLOR_MS
) {
// 彩色原图已展示够再标预加载;人物段优先于 RESULT_PRELOADING 文案
}
if (!tryStartResultTransition() && phase === PHASE.IMAGE_COLOR_POINT_CLOUD) {
tryEnterSubject()
}
// 人物未就绪且结果仍在加载:原图彩色展示满后再进预加载相位
if (
!subjectEntered &&
!subjectReady &&
resultPreloadStatus === 'loading' &&
phase === PHASE.IMAGE_COLOR_POINT_CLOUD &&
Date.now() - phaseStartedAt >= TIMING.IMAGE_COLOR_MS
) {
enter(PHASE.RESULT_PRELOADING)
}
return getUniforms()
}
// —— 人物粗轮廓段(效果图前):彩原图 → 灰抠图 → 约10s → 彩抠图 ——
if (phase === PHASE.SUBJECT_DETECTING) {
// Buffer 已就绪:短过渡进 OUTLINE,不固定空等满 SUBJECT_DETECT_MS
const detectDur = subjectReady
? Math.min(TIMING.SUBJECT_DETECT_MS, 420)
: TIMING.SUBJECT_DETECT_MS
const t = Math.min(1, elapsed / detectDur)
colorProgress = 1 - smootherstep(t)
opacity = 1
reveal = 0
motion = 1
if (!morphing) assembly = 1
subjectProgress = smootherstep(t) * 0.75
outlineProgress = smootherstep(Math.max(0, (t - 0.2) / 0.8)) * 0.5
if (tryStartResultTransition()) return getUniforms()
if (!morphing && t >= 1) {
colorProgress = 0
enter(PHASE.SUBJECT_OUTLINE)
}
return getUniforms()
}
if (phase === PHASE.SUBJECT_OUTLINE) {
colorProgress = 0
opacity = 1
reveal = 0
motion = 0.85
if (!morphing) assembly = 1
const t = Math.min(1, elapsed / TIMING.SUBJECT_OUTLINE_MS)
subjectProgress = 0.7 + smootherstep(t) * 0.3
outlineProgress = 0.45 + smootherstep(t) * 0.55
if (tryStartResultTransition()) return getUniforms()
if (!morphing && t >= 1) {
enter(PHASE.SUBJECT_CLOUD_GRAY)
}
return getUniforms()
}
if (phase === PHASE.SUBJECT_CLOUD_GRAY) {
colorProgress = 0
opacity = 1
reveal = 0
motion = 0.72
if (!morphing) assembly = 1
subjectProgress = 1
outlineProgress = Math.max(0.35, 1 - Math.min(1, elapsed / 2400) * 0.55)
if (!morphing && elapsed >= TIMING.SUBJECT_GRAY_MS) {
enter(PHASE.SUBJECT_CLOUD_COLORIZE)
}
return getUniforms()
}
if (phase === PHASE.SUBJECT_CLOUD_COLORIZE) {
const t = Math.min(1, elapsed / TIMING.COLOR_TRANSITION_MS)
colorProgress = easeInOut(t)
opacity = 1
reveal = 0
motion = 0.7
if (!morphing) assembly = 1
subjectProgress = 1
outlineProgress = 0.35
if (t >= 1) {
colorProgress = 1
enter(PHASE.SUBJECT_CLOUD_COLOR)
}
return getUniforms()
}
if (
phase === PHASE.SUBJECT_CLOUD_COLOR ||
phase === PHASE.SUBJECT_POINT_CLOUD
) {
colorProgress = 1
opacity = 1
reveal = 0
motion = 0.68
if (!morphing) assembly = 1
subjectProgress = 1
outlineProgress = 0.3
tryStartResultTransition()
return getUniforms()
}
// Morph into result point cloud → then gray cloud hold
if (phase === PHASE.RESULT_TRANSITION) {
colorProgress = 0
motion = 1
reveal = 0
opacity = 1
subjectProgress = 0
outlineProgress = 0
if (!morphing) assembly = 1
if (!morphing && elapsed >= TIMING.RESULT_TRANSITION_MS) {
transition = 1
enter(PHASE.RESULT_CLOUD_GRAY)
}
return getUniforms()
}
// 效果图灰色点云 2s
if (phase === PHASE.RESULT_CLOUD_GRAY) {
colorProgress = 0
opacity = 1
reveal = 0
motion = 1
assembly = 1
transition = 1
subjectProgress = 0
outlineProgress = 0
if (elapsed >= TIMING.RESULT_CLOUD_GRAY_MS) {
enter(PHASE.RESULT_CLOUD_COLORIZE)
}
return getUniforms()
}
// 灰 → 彩
if (phase === PHASE.RESULT_CLOUD_COLORIZE) {
const t = Math.min(1, elapsed / TIMING.COLOR_TRANSITION_MS)
colorProgress = easeInOut(t)
opacity = 1
reveal = 0
motion = 1
assembly = 1
transition = 1
subjectProgress = 0
outlineProgress = 0
if (t >= 1) {
colorProgress = 1
enter(PHASE.RESULT_CLOUD_COLOR)
}
return getUniforms()
}
// 效果图彩色点云 3s → 完整效果图
if (phase === PHASE.RESULT_CLOUD_COLOR) {
colorProgress = 1
opacity = 1
reveal = 0
motion = 1
assembly = 1
transition = 1
subjectProgress = 0
outlineProgress = 0
if (elapsed >= TIMING.RESULT_CLOUD_COLOR_MS) {
enter(PHASE.RESULT_IMAGE_REVEAL)
}
return getUniforms()
}
// 点云淡出 → 完整效果图淡入
if (phase === PHASE.RESULT_IMAGE_REVEAL) {
const t = Math.min(1, elapsed / TIMING.RESULT_IMAGE_FADE_MS)
const e = smootherstep(t)
colorProgress = 1
motion = 0.28
assembly = 1
transition = 1
subjectProgress = 0
outlineProgress = 0
reveal = e
opacity = 1 - smootherstep(Math.max(0, (t - 0.05) / 0.95))
if (t >= 1) {
opacity = 0
reveal = 1
enter(PHASE.RESULT_PREVIEW)
}
return getUniforms()
}
// 完整效果图停留 ~2s
if (phase === PHASE.RESULT_PREVIEW) {
opacity = 0
reveal = 1
motion = 0
assembly = 1
subjectProgress = 0
outlineProgress = 0
if (elapsed >= TIMING.RESULT_PREVIEW_MS) {
enter(PHASE.NAVIGATING)
}
return getUniforms()
}
if (phase === PHASE.NAVIGATING) {
opacity = 0
reveal = 1
subjectProgress = 0
outlineProgress = 0
if (elapsed >= TIMING.NAVIGATING_MS && !handoffSent) {
handoffSent = true
finished = true
if (hooks && hooks.onHandoff) hooks.onHandoff()
}
return getUniforms()
}
return getUniforms()
}
return {
start: start,
tick: tick,
setSourceReady: setSourceReady,
setSubjectReady: setSubjectReady,
setSubjectFailed: setSubjectFailed,
setResultPreloadStatus: setResultPreloadStatus,
getPhase: function () {
return phase
},
getUniforms: getUniforms,
isFinished: function () {
return finished
},
}
}
export {
createStateMachine,
PHASE,
}
@@ -0,0 +1,96 @@
/**
* 从软 Mask 提取粗轮廓(边缘带)
* 轮廓只增强边缘,不能替代主体内部粒子
*/
function smoothStep(edge0, edge1, value) {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
return t * t * (3 - 2 * t)
}
/**
* @param {{ width: number, height: number, data: Uint8Array }} mask
* @param {{ threshold?: number }} [options]
* @returns {{ width: number, height: number, data: Uint8Array } | null}
*/
function buildOutlineMask(mask, options) {
options = options || {}
if (!mask || !mask.data || !mask.width || !mask.height) return null
const w = mask.width
const h = mask.height
const src = mask.data
const thr = options.threshold != null ? options.threshold : 40
const out = new Uint8Array(w * h)
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
const c = src[i]
if (c < thr) {
out[i] = 0
continue
}
// 邻域有背景 → 轮廓
const neighbors = [src[i - 1], src[i + 1], src[i - w], src[i + w]]
let isEdge = false
for (let n = 0; n < 4; n++) {
if (neighbors[n] < thr) {
isEdge = true
break
}
}
if (isEdge) {
out[i] = 255
} else {
out[i] = Math.round(c * 0.08)
}
}
}
return {
width: w,
height: h,
data: out,
}
}
/**
* 采样合成权重:主体为主,轮廓仅边缘增强
* Mask data 已是 0~255 soft weight(API Mask 经 smoothStep)
* @returns {number} 0~1
*/
function sampleMaskValue(mask, outline, x, y, imageW, imageH, mode) {
if (!mask || !mask.data) return 1
const mw = mask.width
const mh = mask.height
// Mask 与采样图同尺寸时 1:1;否则按比例映射
const mx = Math.min(
mw - 1,
Math.max(0, Math.floor((x / Math.max(imageW - 1, 1)) * (mw - 1)))
)
const my = Math.min(
mh - 1,
Math.max(0, Math.floor((y / Math.max(imageH - 1, 1)) * (mh - 1)))
)
const mi = my * mw + mx
let body = mask.data[mi] / 255
// 若仍是原始灰度风格,再过一遍 soft step 保底
if (body > 0 && body < 1) {
body = Math.max(body, smoothStep(32 / 255, 220 / 255, body))
}
let edge = 0
if (outline && outline.data && outline.width === mw && outline.height === mh) {
edge = outline.data[mi] / 255
}
if (mode === 'outline') {
return Math.max(0, Math.min(1, body * 0.35 + edge * 0.85))
}
return Math.max(0, Math.min(1, body * 0.94 + edge * 0.12))
}
export {
buildOutlineMask,
sampleMaskValue,
smoothStep,
}
@@ -0,0 +1,336 @@
import {
POINT_VERTEX,
POINT_FRAGMENT,
QUAD_VERTEX,
QUAD_FRAGMENT,
} from './shaders'
import { createTextureManager } from './texture-manager'
import { VISUAL, POINT_CLOUD } from '../constants'
function compileShader(gl, type, source) {
const shader = gl.createShader(type)
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader)
gl.deleteShader(shader)
throw new Error('shader compile: ' + log)
}
return shader
}
function createProgram(gl, vsSrc, fsSrc) {
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc)
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc)
const program = gl.createProgram()
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program)
gl.deleteProgram(program)
throw new Error('program link: ' + log)
}
gl.deleteShader(vs)
gl.deleteShader(fs)
return program
}
function createEmptyCloud(maxPoints) {
const floats = POINT_CLOUD.STRIDE_FLOATS
const data = new Float32Array(maxPoints * floats)
return {
buffer: null,
count: maxPoints,
liveCount: 0,
aspect: 1,
data: data,
}
}
function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) {
const gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
premultipliedAlpha: true,
preserveDrawingBuffer: false,
})
if (!gl) return null
// 正方形缓冲 + 等像素 CSS,保证圆点不被拉扁
const side = Math.max(1, Math.round(Math.min(cssW, cssH)))
const buf = Math.round(side * dpr)
canvas.width = buf
canvas.height = buf
canvas.style.width = `${side}px`
canvas.style.height = `${side}px`
gl.viewport(0, 0, buf, buf)
cssW = side
cssH = side
const pointProgram = createProgram(gl, POINT_VERTEX, POINT_FRAGMENT)
const quadProgram = createProgram(gl, QUAD_VERTEX, QUAD_FRAGMENT)
const textures = createTextureManager(gl, canvas, scaleCanvas, cssW, cssH)
const maxPoints = textures.getMaxPoints()
let emptyCloud = createEmptyCloud(maxPoints)
emptyCloud.buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, emptyCloud.buffer)
gl.bufferData(gl.ARRAY_BUFFER, emptyCloud.data, gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, null)
let cloudA = emptyCloud
let cloudB = emptyCloud
let resultTex = textures.createEmptyTexture()
let hasResult = false
let contentAspect = 1
let pointCount = maxPoints
// Point program attrib/uniform locations
gl.useProgram(pointProgram)
const pointAttr = {
aPosA: gl.getAttribLocation(pointProgram, 'aPosA'),
aPosB: gl.getAttribLocation(pointProgram, 'aPosB'),
aColorA: gl.getAttribLocation(pointProgram, 'aColorA'),
aColorB: gl.getAttribLocation(pointProgram, 'aColorB'),
aSizeA: gl.getAttribLocation(pointProgram, 'aSizeA'),
aSizeB: gl.getAttribLocation(pointProgram, 'aSizeB'),
aAlphaA: gl.getAttribLocation(pointProgram, 'aAlphaA'),
aAlphaB: gl.getAttribLocation(pointProgram, 'aAlphaB'),
aSeedA: gl.getAttribLocation(pointProgram, 'aSeedA'),
aSeedB: gl.getAttribLocation(pointProgram, 'aSeedB'),
aSubjectWeightA: gl.getAttribLocation(pointProgram, 'aSubjectWeightA'),
aSubjectWeightB: gl.getAttribLocation(pointProgram, 'aSubjectWeightB'),
}
const pointUni = {
uTransition: gl.getUniformLocation(pointProgram, 'uTransition'),
uTime: gl.getUniformLocation(pointProgram, 'uTime'),
uColorProgress: gl.getUniformLocation(pointProgram, 'uColorProgress'),
uOpacity: gl.getUniformLocation(pointProgram, 'uOpacity'),
uDpr: gl.getUniformLocation(pointProgram, 'uDpr'),
uMotion: gl.getUniformLocation(pointProgram, 'uMotion'),
uAssembly: gl.getUniformLocation(pointProgram, 'uAssembly'),
uSubjectProgress: gl.getUniformLocation(pointProgram, 'uSubjectProgress'),
uOutlineProgress: gl.getUniformLocation(pointProgram, 'uOutlineProgress'),
uPointer: gl.getUniformLocation(pointProgram, 'uPointer'),
uPointerStrength: gl.getUniformLocation(pointProgram, 'uPointerStrength'),
uPointerRadius: gl.getUniformLocation(pointProgram, 'uPointerRadius'),
uPointerAspect: gl.getUniformLocation(pointProgram, 'uPointerAspect'),
uPointerInflate: gl.getUniformLocation(pointProgram, 'uPointerInflate'),
uPointShape: gl.getUniformLocation(pointProgram, 'uPointShape'),
}
// Quad for result reveal
const quadBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, 1, 1, 1, 0]),
gl.STATIC_DRAW
)
gl.useProgram(quadProgram)
const quadAttr = {
aPosition: gl.getAttribLocation(quadProgram, 'aPosition'),
aUv: gl.getAttribLocation(quadProgram, 'aUv'),
}
const quadUni = {
uTexture: gl.getUniformLocation(quadProgram, 'uTexture'),
uOpacity: gl.getUniformLocation(quadProgram, 'uOpacity'),
uContentScale: gl.getUniformLocation(quadProgram, 'uContentScale'),
uContentOffset: gl.getUniformLocation(quadProgram, 'uContentOffset'),
uBgColor: gl.getUniformLocation(quadProgram, 'uBgColor'),
}
function containFit(aspect, boxW, boxH, fit) {
const f = fit == null ? 0.98 : fit
const maxW = boxW * f
const maxH = boxH * f
let w = maxW
let h = w / aspect
if (h > maxH) {
h = maxH
w = h * aspect
}
return {
scaleX: w / boxW,
scaleY: h / boxH,
offsetX: (boxW - w) * 0.5 / boxW,
offsetY: (boxH - h) * 0.5 / boxH,
}
}
function bindCloudAttribs(
bufferObj,
posLoc,
colorLoc,
sizeLoc,
alphaLoc,
seedLoc,
weightLoc
) {
const stride = POINT_CLOUD.STRIDE_FLOATS * 4
gl.bindBuffer(gl.ARRAY_BUFFER, bufferObj.buffer)
if (posLoc >= 0) {
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0)
}
if (colorLoc >= 0) {
gl.enableVertexAttribArray(colorLoc)
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, 8)
}
if (sizeLoc >= 0) {
gl.enableVertexAttribArray(sizeLoc)
gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, stride, 20)
}
if (alphaLoc >= 0) {
gl.enableVertexAttribArray(alphaLoc)
gl.vertexAttribPointer(alphaLoc, 1, gl.FLOAT, false, stride, 24)
}
if (seedLoc >= 0) {
gl.enableVertexAttribArray(seedLoc)
gl.vertexAttribPointer(seedLoc, 1, gl.FLOAT, false, stride, 28)
}
if (weightLoc >= 0) {
gl.enableVertexAttribArray(weightLoc)
gl.vertexAttribPointer(weightLoc, 1, gl.FLOAT, false, stride, 32)
}
}
function setMainCloud(entry) {
if (!entry || !entry.pointCloudBuffer) return
cloudA = entry.pointCloudBuffer
cloudB = entry.pointCloudBuffer
contentAspect = entry.aspect || cloudA.aspect || 1
pointCount = cloudA.count || maxPoints
}
/** Promote current target to A, set B = new target (particle morph) */
function transitionToCloud(entry) {
if (!entry || !entry.pointCloudBuffer) return
cloudA = cloudB
cloudB = entry.pointCloudBuffer
contentAspect = entry.aspect || cloudB.aspect || contentAspect
pointCount = Math.max(cloudA.count || 0, cloudB.count || 0, maxPoints)
}
function setResultTexture(entry) {
if (!entry || !entry.texture) {
hasResult = false
return
}
resultTex = entry.texture
hasResult = true
if (entry.aspect) contentAspect = entry.aspect
}
function draw(uniformsIn) {
const u = uniformsIn || {}
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(
VISUAL.STAGE_BG[0],
VISUAL.STAGE_BG[1],
VISUAL.STAGE_BG[2],
1
)
gl.clear(gl.COLOR_BUFFER_BIT)
const pointOpacity = u.opacity != null ? u.opacity : 1
// 完整效果图改由页面层按预览框位展示,避免 WebGL 全屏图与预览错位
if (pointOpacity > 0.001 && cloudA && cloudB) {
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.useProgram(pointProgram)
bindCloudAttribs(
cloudA,
pointAttr.aPosA,
pointAttr.aColorA,
pointAttr.aSizeA,
pointAttr.aAlphaA,
pointAttr.aSeedA,
pointAttr.aSubjectWeightA
)
bindCloudAttribs(
cloudB,
pointAttr.aPosB,
pointAttr.aColorB,
pointAttr.aSizeB,
pointAttr.aAlphaB,
pointAttr.aSeedB,
pointAttr.aSubjectWeightB
)
gl.uniform1f(pointUni.uTransition, u.transition != null ? u.transition : 1)
gl.uniform1f(pointUni.uTime, u.time || 0)
gl.uniform1f(
pointUni.uColorProgress,
u.colorProgress != null ? u.colorProgress : 0
)
gl.uniform1f(pointUni.uOpacity, pointOpacity)
gl.uniform1f(pointUni.uDpr, dpr)
gl.uniform1f(pointUni.uMotion, u.motion != null ? u.motion : 1)
gl.uniform1f(pointUni.uAssembly, u.assembly != null ? u.assembly : 1)
gl.uniform1f(
pointUni.uSubjectProgress,
u.subjectProgress != null ? u.subjectProgress : 0
)
gl.uniform1f(
pointUni.uOutlineProgress,
u.outlineProgress != null ? u.outlineProgress : 0
)
gl.uniform2f(
pointUni.uPointer,
u.pointerX != null ? u.pointerX : 0,
u.pointerY != null ? u.pointerY : 0
)
gl.uniform1f(
pointUni.uPointerStrength,
u.pointerStrength != null ? u.pointerStrength : 0
)
gl.uniform1f(
pointUni.uPointerRadius,
u.pointerRadius != null ? u.pointerRadius : 0.38
)
gl.uniform1f(
pointUni.uPointerAspect,
u.pointerAspect != null ? u.pointerAspect : 1
)
gl.uniform1f(
pointUni.uPointerInflate,
u.pointerInflate != null ? u.pointerInflate : 0.14
)
gl.uniform1f(
pointUni.uPointShape,
u.pointShape != null ? u.pointShape : 0
)
gl.drawArrays(gl.POINTS, 0, pointCount)
}
}
function destroy() {
textures.destroy()
try {
if (emptyCloud.buffer) gl.deleteBuffer(emptyCloud.buffer)
gl.deleteBuffer(quadBuffer)
gl.deleteProgram(pointProgram)
gl.deleteProgram(quadProgram)
gl.deleteTexture(resultTex)
} catch (e) {}
}
return {
gl: gl,
textures: textures,
draw: draw,
setMainCloud: setMainCloud,
transitionToCloud: transitionToCloud,
setResultTexture: setResultTexture,
destroy: destroy,
}
}
export {
createRenderer,
}
@@ -0,0 +1,234 @@
/**
* Real GPU point cloud (GL_POINTS) — Tripo-style.
* Circle / square particle shapes via uPointShape (0=圆, 1=方).
* subjectWeight + uSubjectProgress / uOutlineProgress:人物阶段背景消散/轮廓(结果阶段恒 0)
*/
const POINT_VERTEX = `
precision mediump float;
attribute vec2 aPosA;
attribute vec2 aPosB;
attribute vec3 aColorA;
attribute vec3 aColorB;
attribute float aSizeA;
attribute float aSizeB;
attribute float aAlphaA;
attribute float aAlphaB;
attribute float aSeedA;
attribute float aSeedB;
attribute float aSubjectWeightA;
attribute float aSubjectWeightB;
uniform float uTransition;
uniform float uTime;
uniform float uColorProgress;
uniform float uOpacity;
uniform float uDpr;
uniform float uMotion;
uniform float uAssembly;
uniform float uSubjectProgress;
uniform float uOutlineProgress;
uniform vec2 uPointer;
uniform float uPointerStrength;
uniform float uPointerRadius;
uniform float uPointerAspect;
uniform float uPointerInflate;
varying vec4 vColor;
float hash(float n) {
return fract(sin(n * 127.1) * 43758.5453);
}
void main() {
float t = smoothstep(0.0, 1.0, uTransition);
float fidelity = clamp(uColorProgress, 0.0, 1.0);
vec2 position = mix(aPosA, aPosB, t);
float seed = mix(aSeedA, aSeedB, t);
float size = mix(aSizeA, aSizeB, t);
float alpha = mix(aAlphaA, aAlphaB, t);
float subjectW = mix(aSubjectWeightA, aSubjectWeightB, t);
float s = seed * 6.2831853;
float depth = clamp((size - 1.45) / 1.8, 0.0, 1.0);
vec2 randomOffset = vec2(hash(seed), hash(seed + 17.3)) - 0.5;
position += randomOffset * 0.14 * (1.0 - uAssembly);
float spread = 1.0 + 0.012 * (0.55 + depth * 0.45);
position *= spread;
float persNear = mix(1.1, 1.035, fidelity);
float persFar = mix(0.88, 0.97, fidelity);
position *= mix(persFar, persNear, depth);
vec2 cam = vec2(
sin(uTime * 0.55) * 0.028,
cos(uTime * 0.4) * 0.02
);
position += cam * (depth * 1.55 - 0.45) * mix(1.0, 0.28, fidelity);
float amp =
(0.007 + depth * 0.012) * uMotion * mix(1.0, 0.45, fidelity);
vec2 wave = vec2(
sin(uTime * 1.35 + s + position.y * 6.5) * 0.55 +
sin(uTime * 0.8 + s * 2.0) * 0.45,
cos(uTime * 1.15 + s * 1.5 + position.x * 6.0) * 0.55 +
cos(uTime * 0.65 + s) * 0.45
);
position += wave * amp;
float breath = sin(uTime * 1.35 + s * 0.35) * 0.5 + 0.5;
float breathOut = sin(uTime * 1.35 + seed * 4.0);
position *= 1.0 + breathOut * 0.018 * uMotion;
size *= 1.0 + (breath - 0.5) * 0.16 * uMotion;
alpha *= 1.0 + (breath - 0.5) * 0.08 * uMotion;
// 人物阶段:背景消散 + 轮廓/主体聚拢(uSubjectProgress=0 时无影响,RESULT_* 观感不变)
float sp = clamp(uSubjectProgress, 0.0, 1.0);
float op = clamp(uOutlineProgress, 0.0, 1.0);
if (sp > 0.001 || op > 0.001) {
float bgFade = 1.0 - smoothstep(0.18, 0.72, subjectW);
position *= 1.0 + bgFade * sp * 0.1;
float keep = smoothstep(0.1, 0.5, subjectW);
alpha *= mix(1.0, keep * keep * max(subjectW, 0.08), sp);
if (subjectW < 0.15 && sp > 0.5) {
alpha *= 0.05;
}
// 轮廓:size +10~15%,alpha +10~20%
float edgeBoost = 1.0 - abs(subjectW - 0.48) * 2.4;
edgeBoost = clamp(edgeBoost, 0.0, 1.0);
size *= 1.0 + edgeBoost * op * 0.14;
alpha *= 1.0 + edgeBoost * op * 0.16;
position *= mix(1.0, 0.965 + subjectW * 0.035, sp * 0.65);
}
if (uPointerStrength > 0.001) {
vec2 delta = position - uPointer;
float aspect = max(uPointerAspect, 0.25);
vec2 sd = vec2(delta.x * aspect, delta.y);
float dist = length(sd);
float radius = max(uPointerRadius, 0.08);
float tn = clamp(dist / radius, 0.0, 1.0);
float disk = 1.0 - smoothstep(0.0, 1.0, tn);
disk = disk * disk * (3.0 - 2.0 * disk);
float strength = smoothstep(0.0, 1.0, uPointerStrength);
float amount = max(uPointerInflate, 0.08) * disk * strength;
vec2 sdOut = sd * (1.0 + amount);
position = uPointer + vec2(sdOut.x / aspect, sdOut.y);
size *= 1.0 + amount * 2.2;
alpha = min(1.0, alpha * (1.0 + disk * strength * 0.08));
}
vec3 grayA = vec3(dot(aColorA, vec3(0.299, 0.587, 0.114)));
vec3 grayB = vec3(dot(aColorB, vec3(0.299, 0.587, 0.114)));
grayA = vec3(0.82 + grayA.r * 0.32);
grayB = vec3(0.82 + grayB.r * 0.32);
/* 彩色段贴原图色相,略提亮避免发暗 */
vec3 photoA = clamp(aColorA, 0.0, 1.0);
vec3 photoB = clamp(aColorB, 0.0, 1.0);
float lumA = dot(photoA, vec3(0.299, 0.587, 0.114));
float lumB = dot(photoB, vec3(0.299, 0.587, 0.114));
photoA = clamp(mix(vec3(lumA), photoA, 1.08) * 1.16 + 0.02, 0.0, 1.0);
photoB = clamp(mix(vec3(lumB), photoB, 1.08) * 1.16 + 0.02, 0.0, 1.0);
vec3 colorA = mix(grayA, photoA, fidelity);
vec3 colorB = mix(grayB, photoB, fidelity);
vec3 color = mix(colorA, colorB, t);
float brightStyle =
mix(0.72, 1.28, depth) *
(0.92 + 0.14 * hash(seed * 91.7));
brightStyle *= 0.96 + 0.04 * sin(uTime * 1.9 + s);
float brightPhoto =
mix(1.12, 1.24, depth) *
(0.99 + 0.03 * hash(seed * 91.7));
color = clamp(color * mix(brightStyle, brightPhoto, fidelity), 0.0, 1.0);
float alphaStyle = alpha * uOpacity * mix(0.7, 1.0, depth);
float alphaPhoto = alpha * uOpacity * mix(0.85, 1.0, depth);
vColor = vec4(color, mix(alphaStyle, alphaPhoto, fidelity));
float sizeStyle = mix(0.78, 1.18, depth);
float sizePhoto = mix(0.85, 1.12, depth);
float sizeMul = mix(sizeStyle, sizePhoto, fidelity);
sizeMul *= mix(0.97 + 0.03 * sin(uTime * 1.7 + s), 1.0, fidelity * 0.5);
gl_PointSize = max(1.5, size * sizeMul * uDpr);
gl_Position = vec4(position, 0.0, 1.0);
}
`
const POINT_FRAGMENT = `
precision mediump float;
varying vec4 vColor;
uniform float uPointShape; // 0=圆形, 1=方形, 中间值= morph
void main() {
vec2 p = gl_PointCoord - vec2(0.5);
float d = length(p);
vec2 q = abs(p);
float circleA = 1.0 - smoothstep(0.38, 0.5, d);
float squareA = 1.0 - smoothstep(0.36, 0.48, max(q.x, q.y));
float shape = clamp(uPointShape, 0.0, 1.0);
float alpha = mix(circleA, squareA, shape);
if (alpha < 0.04) {
discard;
}
float core = 1.0 - smoothstep(0.0, 0.26, d);
vec3 color = vColor.rgb * (1.0 + core * 0.12);
gl_FragColor = vec4(color, vColor.a * alpha);
}
`
/** Fullscreen textured quad for result image reveal */
const QUAD_VERTEX = `
attribute vec2 aPosition;
attribute vec2 aUv;
varying vec2 vUv;
void main() {
vUv = aUv;
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const QUAD_FRAGMENT = `
precision mediump float;
varying vec2 vUv;
uniform sampler2D uTexture;
uniform float uOpacity;
uniform vec2 uContentScale;
uniform vec2 uContentOffset;
uniform vec4 uBgColor;
void main() {
vec2 cuv = (vUv - uContentOffset) / max(uContentScale, vec2(0.0001));
if (cuv.x < 0.0 || cuv.x > 1.0 || cuv.y < 0.0 || cuv.y > 1.0) {
gl_FragColor = vec4(uBgColor.rgb, 1.0);
return;
}
vec4 tex = texture2D(uTexture, cuv);
float a = tex.a * uOpacity;
vec3 rgb = mix(uBgColor.rgb, tex.rgb, a);
gl_FragColor = vec4(rgb, 1.0);
}
`
export {
POINT_VERTEX,
POINT_FRAGMENT,
QUAD_VERTEX,
QUAD_FRAGMENT,
}
@@ -0,0 +1,542 @@
import { VISUAL, POINT_CLOUD, PATHS } from '../constants'
import { createPointCloudSampler } from '../point-cloud-sampler'
import * as logoCloudCache from '../logo-cloud-cache'
import { buildOutlineMask } from '../subject-outline'
import * as maskProcessor from '../mask-processor'
let _perfLog = false
/**
* Image → Texture + PointCloud Float32Array + GPU Buffer (preload only).
*/
function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
const cache = {}
let maxTexSize = VISUAL.MAX_TEX_SIZE
let maxPoints = POINT_CLOUD.MEDIUM
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
let scaleCtx = null
if (scaleCanvas) {
try {
// getImageData 频繁读回:声明 willReadFrequently 消除告警并提升性能
scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true })
} catch (e) {
try {
scaleCtx = scaleCanvas.getContext('2d')
} catch (e2) {
scaleCtx = null
}
}
}
const sampler = createPointCloudSampler(
scaleCanvas,
viewportW,
viewportH,
scaleCtx
)
function pickTier() {
try {
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1
const cores = (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4
if (dpr >= 2.5 && cores >= 8) {
maxPoints = POINT_CLOUD.HIGH
sampleSide = POINT_CLOUD.SAMPLE_HIGH
maxTexSize = VISUAL.MAX_TEX_SIZE
maskSide = POINT_CLOUD.MASK_HIGH
subjectPoints = POINT_CLOUD.SUBJECT_HIGH
quality = 'high'
} else if (cores <= 4) {
maxPoints = POINT_CLOUD.LOW
sampleSide = POINT_CLOUD.SAMPLE_LOW
maxTexSize = VISUAL.LOW_TEX_SIZE
maskSide = POINT_CLOUD.MASK_LOW
subjectPoints = POINT_CLOUD.SUBJECT_LOW
quality = 'low'
} else {
maxPoints = POINT_CLOUD.MEDIUM
sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
maxTexSize = VISUAL.MAX_TEX_SIZE
maskSide = POINT_CLOUD.MASK_MEDIUM
subjectPoints = POINT_CLOUD.SUBJECT_MEDIUM
quality = 'medium'
}
} catch (e) {
maxPoints = POINT_CLOUD.MEDIUM
sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
maskSide = POINT_CLOUD.MASK_MEDIUM
subjectPoints = POINT_CLOUD.SUBJECT_MEDIUM
quality = 'medium'
}
}
let maskSide = POINT_CLOUD.MASK_MEDIUM
let subjectPoints = POINT_CLOUD.SUBJECT_MEDIUM
let quality = 'medium'
pickTier()
function setMaxSize(n) {
maxTexSize = n || VISUAL.MAX_TEX_SIZE
}
function createEmptyTexture() {
const tex = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0])
)
gl.bindTexture(gl.TEXTURE_2D, null)
return tex
}
function uploadCloudBuffer(cloud) {
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, cloud.data, gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, null)
return {
buffer: buffer,
count: cloud.maxPoints,
liveCount: cloud.count,
aspect: cloud.aspect,
}
}
/**
* 加载图片并严格在 onload 之后再交给后续逻辑(避免 TMA 未定义行为告警)
*/
function loadImage(src) {
return new Promise(function (resolve, reject) {
if (!src) {
reject(new Error('empty src'))
return
}
let settled = false
const img = new Image()
// 跨域效果图需 CORS,否则无法 getImageData 采样
if (/^https?:\/\//i.test(src)) {
img.crossOrigin = 'anonymous'
}
function finishOk() {
if (settled) return
setTimeout(function () {
if (settled) return
const w = img.width || 0
const h = img.height || 0
if (!w || !h) {
settled = true
reject(new Error('image empty after onload'))
return
}
settled = true
resolve({ image: img, path: src, width: w, height: h })
}, 0)
}
function finishErr(err) {
if (settled) return
settled = true
reject(err || new Error('image load fail'))
}
img.onload = finishOk
img.onerror = finishErr
img.src = src
})
}
function uploadTexture(image, path) {
if (!image || !image.width || !image.height) {
throw new Error('uploadTexture: image not ready')
}
const srcW = image.width || 1
const srcH = image.height || 1
let drawW = srcW
let drawH = srcH
const longest = Math.max(srcW, srcH)
if (longest > maxTexSize) {
const s = maxTexSize / longest
drawW = Math.max(1, Math.round(srcW * s))
drawH = Math.max(1, Math.round(srcH * s))
}
const tex = createEmptyTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
try {
if ((drawW !== srcW || drawH !== srcH) && scaleCanvas && scaleCtx) {
scaleCanvas.width = drawW
scaleCanvas.height = drawH
scaleCtx.clearRect(0, 0, drawW, drawH)
scaleCtx.drawImage(image, 0, 0, drawW, drawH)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
scaleCanvas
)
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
}
} catch (e) {
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
} catch (e2) {
gl.bindTexture(gl.TEXTURE_2D, null)
throw e2
}
}
gl.bindTexture(gl.TEXTURE_2D, null)
return {
texture: tex,
width: srcW,
height: srcH,
aspect: srcW / Math.max(srcH, 1),
path: path,
}
}
function get(src) {
return cache[src] || null
}
function load(src, options) {
options = options || {}
if (!src) return Promise.reject(new Error('empty src'))
if (cache[src] && cache[src].pointCloudBuffer) {
return Promise.resolve(cache[src])
}
if (cache[src] && cache[src]._promise) {
return cache[src]._promise
}
const removeBackground = options.removeBackground !== false
const logoCached =
src === PATHS.LOGO ? logoCloudCache.getCachedLogoCloud() : null
// Logo 已在首页预采样:立刻上传 GPU buffer,纹理后台补齐
if (logoCached && logoCached.cloud && logoCached.cloud.data) {
const entry = {
_promise: null,
texture: null,
pointCloud: null,
pointCloudBuffer: null,
preloadStatus: 'loading',
}
try {
entry.pointCloud = {
data: logoCached.cloud.data,
count: logoCached.cloud.count,
maxPoints: logoCached.cloud.maxPoints || maxPoints,
aspect: logoCached.cloud.aspect || logoCached.aspect || 1,
}
entry.pointCloudBuffer = uploadCloudBuffer(entry.pointCloud)
entry.width = logoCached.width || 1
entry.height = logoCached.height || 1
entry.aspect =
logoCached.aspect || entry.width / Math.max(entry.height, 1)
entry.preloadStatus = 'ready'
cache[src] = entry
loadImage(src)
.then(function (res) {
if (!res.image || !res.width || !res.height) return
try {
const uploaded = uploadTexture(res.image, res.path)
entry.image = res.image
entry.texture = uploaded.texture
entry.width = uploaded.width
entry.height = uploaded.height
entry.aspect = uploaded.aspect
} catch (e) {}
})
.catch(function () {})
return Promise.resolve(entry)
} catch (e) {
delete cache[src]
}
}
const entry = {
_promise: null,
texture: null,
pointCloud: null,
pointCloudBuffer: null,
preloadStatus: 'loading',
}
cache[src] = entry
entry._promise = loadImage(src)
.then(function (res) {
entry.image = res.image
entry.path = res.path
if (!res.image || !res.width || !res.height) {
throw new Error('image not ready')
}
const uploaded = uploadTexture(res.image, res.path)
entry.texture = uploaded.texture
entry.width = uploaded.width
entry.height = uploaded.height
entry.aspect = uploaded.aspect
const cloud = sampler.sample(entry, {
maxPoints: maxPoints,
maxSide: sampleSide,
removeBackground: removeBackground,
})
entry.pointCloud = cloud
entry.pointCloudBuffer = uploadCloudBuffer(cloud)
entry.preloadStatus = 'ready'
entry._promise = null
if (src === PATHS.LOGO) {
logoCloudCache.putCachedLogoCloud({
width: entry.width,
height: entry.height,
aspect: entry.aspect,
maxPoints: cloud.maxPoints,
cloud: cloud,
viewportW: viewportW,
viewportH: viewportH,
})
}
return entry
})
.catch(function (err) {
entry.preloadStatus = 'error'
delete cache[src]
throw err
})
return entry._promise
}
/**
* 后台:API Mask 图 → 人物点云 Buffer(失败返回 null,不抛)
* 有 maskUrl 时不再做启发式抠图
* @param {string} src 源图 key
* @param {{ maskUrl?: string }} [options]
*/
function prepareSubject(src, options) {
options = options || {}
const maskUrl = options.maskUrl || ''
if (!src || !maskUrl) return Promise.resolve(null)
const cachedEntry = cache[src]
if (
cachedEntry &&
cachedEntry.subjectCloudBuffer &&
cachedEntry._maskUrl === maskUrl
) {
return Promise.resolve(cachedEntry)
}
if (
cachedEntry &&
cachedEntry._subjectPromise &&
cachedEntry._maskUrl === maskUrl
) {
return cachedEntry._subjectPromise
}
if (cachedEntry && cachedEntry._maskUrl !== maskUrl) {
cachedEntry.subjectCloudBuffer = null
cachedEntry.subjectMask = null
cachedEntry.outlineMask = null
cachedEntry._subjectPromise = null
}
const run = function (entry) {
if (!entry || !entry.image) {
return Promise.resolve(null)
}
const t0 = Date.now()
entry._maskUrl = maskUrl
entry._subjectPromise = loadImage(maskUrl)
.then(function (maskRes) {
if (!maskRes || !maskRes.image) {
entry.subjectFailed = true
entry._subjectPromise = null
return null
}
const sw = entry.width || entry.image.width || 0
const sh = entry.height || entry.image.height || 0
const mw = maskRes.width || maskRes.image.width || 0
const mh = maskRes.height || maskRes.image.height || 0
const aligned = maskProcessor.resolveAlignedSize(
sw,
sh,
mw,
mh,
maskSide
)
if (!aligned) {
entry.subjectFailed = true
entry._subjectPromise = null
return null
}
const mask = maskProcessor.decodeMaskImage(
maskRes.image,
scaleCtx,
scaleCanvas,
aligned.width,
aligned.height
)
const detectMs = Date.now() - t0
if (!mask) {
entry.subjectFailed = true
entry._subjectPromise = null
return null
}
const t1 = Date.now()
const outline = buildOutlineMask(mask)
const maskMs = Date.now() - t1
try {
const t2 = Date.now()
// 颜色采样与 Mask 使用同一逻辑边长,保证坐标对齐
const cloud = sampler.sample(entry, {
maxPoints: subjectPoints,
maxSide: Math.max(aligned.width, aligned.height),
removeBackground: false,
subjectMask: mask,
outlineMask: outline,
maskMode: 'subject',
forceSampleSize: {
width: aligned.width,
height: aligned.height,
},
})
const sampleMs = Date.now() - t2
const t3 = Date.now()
entry.subjectMask = mask
entry.outlineMask = outline
entry.subjectCloudBuffer = uploadCloudBuffer(cloud)
const uploadMs = Date.now() - t3
entry.subjectCloud = {
count: cloud.count,
maxPoints: cloud.maxPoints,
aspect: cloud.aspect,
}
entry.subjectFailed = false
entry._subjectPromise = null
if (_perfLog) {
console.log(
'[PointCloud] apiMask:' +
detectMs +
'ms outline:' +
maskMs +
'ms sample:' +
sampleMs +
'ms upload:' +
uploadMs +
'ms particles:' +
cloud.count +
' src:' +
sw +
'x' +
sh +
' mask:' +
mw +
'x' +
mh +
' target:' +
aligned.width +
'x' +
aligned.height +
' quality:' +
quality
)
}
return entry
} catch (err) {
entry.subjectFailed = true
entry._subjectPromise = null
return null
}
})
.catch(function () {
entry.subjectFailed = true
entry._subjectPromise = null
return null
})
return entry._subjectPromise
}
if (cachedEntry && cachedEntry.image) {
return run(cachedEntry)
}
return load(src, { removeBackground: true }).then(function (entry) {
return run(entry)
})
}
function getSubject(src) {
const e = cache[src]
if (!e || !e.subjectCloudBuffer) return null
return e
}
function destroy() {
Object.keys(cache).forEach(function (key) {
const e = cache[key]
if (e && e.texture) {
try {
gl.deleteTexture(e.texture)
} catch (err) {}
}
if (e && e.pointCloudBuffer && e.pointCloudBuffer.buffer) {
try {
gl.deleteBuffer(e.pointCloudBuffer.buffer)
} catch (err) {}
}
if (e && e.subjectCloudBuffer && e.subjectCloudBuffer.buffer) {
try {
gl.deleteBuffer(e.subjectCloudBuffer.buffer)
} catch (err) {}
}
if (e) {
e.image = null
e.subjectMask = null
e.outlineMask = null
e.subjectCloud = null
e.pointCloud = null
e._promise = null
e._subjectPromise = null
}
delete cache[key]
})
}
return {
load: load,
get: get,
prepareSubject: prepareSubject,
getSubject: getSubject,
setMaxSize: setMaxSize,
destroy: destroy,
createEmptyTexture: createEmptyTexture,
getMaxPoints: function () {
return maxPoints
},
}
}
export {
createTextureManager,
}
+855
View File
@@ -0,0 +1,855 @@
<template>
<div class="gen-page" :class="{ 'is-result': showResult }">
<!-- ===== 生成中 ===== -->
<template v-if="!showResult">
<DyNavBar
theme="dark"
title="生成中"
back-text="‹ 返回"
right-text="记录"
border
@back="goHome"
@right="goRecords"
/>
<div class="stage-area">
<div class="stage-center">
<div class="stage-shell" :style="shellStyle">
<div class="stage-box" :style="stageStyle">
<div class="stage-layer">
<ImagePointCloud
point-shape="circle"
:side="stageSide"
:fading="cloudFading"
:image-url="sourceUrl"
:mask-url="maskUrl"
:result-url="resultUrl"
@phasechange="onPhaseChange"
@handoff="onHandoff"
/>
</div>
<img
v-if="resultUrl"
class="stage-layer result-layer"
:src="resultUrl"
alt=""
>
</div>
</div>
<div v-if="errorTip" class="error-tip">{{ errorTip }}</div>
</div>
</div>
<div class="progress-dock">
<div class="bar"><div class="inner" :style="{ width: `${progress}%` }"></div></div>
<div class="meta">
<div>
<div class="tip">{{ tip }}</div>
<div class="sub">{{ subTip }}</div>
</div>
<span>{{ Math.floor(progress) }}%</span>
</div>
</div>
</template>
<!-- ===== 效果预览(对齐设计图) ===== -->
<template v-else>
<DyNavBar
theme="light"
title="效果预览"
right-text="生图记录"
border
@back="goBack"
@right="goRecords"
/>
<div class="result-body">
<div class="result-head">
<div class="status-row">
<span class="status-dot" />
<span class="status-text">生成完成</span>
</div>
<h1 class="headline">专属效果已就绪</h1>
<p class="subline">确认满意后即可提交制作</p>
</div>
<div class="preview-card" @click="openZoom">
<img v-if="resultUrl" class="preview-img" :src="resultUrl" alt="效果图">
<div v-if="sourceUrl" class="source-polaroid" aria-hidden="true">
<img :src="sourceUrl" alt="">
</div>
<button class="zoom-btn" type="button" @click.stop="openZoom">点击放大</button>
</div>
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
<div v-if="errorTip" class="error-tip light">{{ errorTip }}</div>
</div>
<div class="result-footer">
<button class="btn-remake" type="button" @click="goHome">重新制作</button>
<button
class="btn-submit"
type="button"
:disabled="ordering || !taskId"
@click="openConfirmModal"
>
满意,提交制作
</button>
</div>
<!-- 确认弹窗 -->
<div v-if="showConfirmModal" class="confirm-mask" @click.self="showConfirmModal = false">
<div class="confirm-sheet">
<div class="confirm-title">确认效果满意</div>
<div class="confirm-body">
<p>请仔细确认效果是否满意后再提交</p>
<p class="confirm-sub">提交制作后将进入制作流程,请确认效果满意后再继续</p>
</div>
<div class="confirm-actions">
<button class="btn-cancel" type="button" @click="showConfirmModal = false">返回查看</button>
<button
class="btn-ok"
type="button"
:disabled="ordering"
@click="confirmSubmit"
>
提交制作
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showImagePreview, showToast } from 'vant'
import * as douyinApi from '@/api/douyin'
import { douyinConfig as config } from '@/config/douyin'
import ImagePointCloud from './components/ImagePointCloud.vue'
import DyNavBar from './components/DyNavBar.vue'
import { takePendingUpload } from './pendingUpload'
const SUBMITTED_KEY = 'douyin_order_submitted'
function isSubmittedBizError(msg: string) {
return /已提交制作|不能继续上传|不能继续生图/.test(String(msg || ''))
}
const route = useRoute()
const router = useRouter()
const taskId = ref(String(route.query.task_id || sessionStorage.getItem('douyin_task_id') || ''))
const pid = ref(String(route.query.pid || sessionStorage.getItem('douyin_pid') || ''))
const sourceUrl = ref(sessionStorage.getItem('douyin_source_preview') || '')
const maskUrl = ref('')
const resultUrl = ref('')
const imageId = ref('')
const tip = ref('准备中')
const subTip = ref('正在启动')
const progress = ref(2)
const showResult = ref(false)
const cloudFading = ref(false)
const ordering = ref(false)
const errorTip = ref('')
const showConfirmModal = ref(false)
/** 红框正方形边长:首屏即算好,不依赖 CSS min() */
function calcStageSide() {
if (typeof window === 'undefined') return 280
const w = window.innerWidth || 375
const h = window.innerHeight || 667
// 约屏宽 72%、屏高 40%,上限 320,贴近预览卡片区域
return Math.max(180, Math.round(Math.min(w * 0.72, h * 0.4, 320)))
}
const stageSide = ref(calcStageSide())
const stageStyle = computed(() => ({
width: `${stageSide.value}px`,
height: `${stageSide.value}px`,
}))
const shellStyle = computed(() => ({
width: showResult.value ? `${stageSide.value + 32}px` : `${stageSide.value}px`,
}))
let pollTimer: ReturnType<typeof setInterval> | null = null
let softTimer: ReturnType<typeof setInterval> | null = null
let handoffDone = false
let maskAborted = false
let uploading = false
let fadeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
stageSide.value = calcStageSide()
}
function goHome() {
router.replace(config.routes.home)
}
/** 预览顶栏返回:有历史则回上一页,否则回首页 */
function goBack() {
if (window.history.length > 1) {
router.back()
return
}
router.replace(config.routes.home)
}
function goRecords() {
router.push(config.routes.records)
}
let progressAnimTimer: ReturnType<typeof setInterval> | null = null
function clearTimers() {
if (pollTimer) clearInterval(pollTimer)
if (softTimer) clearInterval(softTimer)
if (fadeTimer) clearTimeout(fadeTimer)
if (progressAnimTimer) clearInterval(progressAnimTimer)
pollTimer = null
softTimer = null
fadeTimer = null
progressAnimTimer = null
}
function onPhaseChange(payload: { phase: string; tip: string; subTip: string }) {
tip.value = payload.tip || tip.value
subTip.value = payload.subTip || subTip.value
const phase = payload.phase
if (phase?.startsWith('SUBJECT')) progress.value = Math.max(progress.value, 58)
if (phase?.startsWith('RESULT_CLOUD')) progress.value = Math.max(progress.value, 90)
if (phase === 'RESULT_IMAGE_REVEAL' && resultUrl.value) {
startSmoothReveal()
}
if (phase === 'RESULT_IMAGE_REVEAL' || phase === 'RESULT_PREVIEW' || phase === 'NAVIGATING') {
// 预览前由 startSmoothReveal 动画到 100,这里兜底
if (showResult.value) progress.value = 100
}
}
/** 平滑加速到目标进度 */
function animateProgressTo(target: number, durationMs = 480): Promise<void> {
return new Promise((resolve) => {
if (progressAnimTimer) {
clearInterval(progressAnimTimer)
progressAnimTimer = null
}
const from = progress.value
const to = Math.max(from, Math.min(100, target))
if (to <= from + 0.2) {
progress.value = to
resolve()
return
}
const start = Date.now()
progressAnimTimer = setInterval(() => {
const t = Math.min(1, (Date.now() - start) / durationMs)
// ease-out
const eased = 1 - (1 - t) * (1 - t)
progress.value = from + (to - from) * eased
if (t >= 1) {
if (progressAnimTimer) clearInterval(progressAnimTimer)
progressAnimTimer = null
progress.value = to
resolve()
}
}, 32)
})
}
function startSmoothReveal() {
if (showResult.value || !resultUrl.value) return
// 先把进度条跑到 100%,再切效果预览,保证用户能看到满格
animateProgressTo(100, 520).then(() => {
if (showResult.value) return
cloudFading.value = true
requestAnimationFrame(() => {
showResult.value = true
})
sessionStorage.setItem('douyin_task_id', taskId.value)
sessionStorage.setItem('douyin_image_id', imageId.value)
sessionStorage.setItem('douyin_result_url', resultUrl.value)
})
}
function enterResultView() {
if (handoffDone) return
handoffDone = true
// 停轮询/软进度,但保留进度冲满动画
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
if (softTimer) {
clearInterval(softTimer)
softTimer = null
}
if (fadeTimer) {
clearTimeout(fadeTimer)
fadeTimer = null
}
startSmoothReveal()
}
function onHandoff() {
enterResultView()
}
function startSoftProgress() {
softTimer = setInterval(() => {
if (showResult.value) return
// 效果图已到:快速冲到 96%,留给揭示动画收尾到 100%
if (resultUrl.value) {
if (progress.value < 96) {
progress.value = Math.min(96, progress.value + 3.2)
}
return
}
const cap = config.poll.softCap
if (progress.value >= cap) return
// 整体加快:前段更快,后段仍有余量
let step = 2.4
if (progress.value >= 35) step = 1.4
if (progress.value >= 60) step = 0.85
if (progress.value >= 75) step = 0.45
progress.value = Math.min(cap, progress.value + step)
}, 280)
}
function startMaskFetch() {
if (!pid.value) return
maskAborted = false
douyinApi
.waitMaskUrl({ pid: pid.value, intervalMs: 2000, timeoutMs: 60000 })
.then((res) => {
if (maskAborted || showResult.value) return
if (res && res.url) {
maskUrl.value = res.url
sessionStorage.setItem('douyin_mask_url', res.url)
}
})
.catch(() => {})
}
function pollStatus() {
if (!taskId.value) return
douyinApi.getImageGenStatus(taskId.value).then((res: any) => {
const parsed = douyinApi.parseImageGenStatus(res, taskId.value)
if (parsed.status === 'failed') {
clearTimers()
errorTip.value = parsed.message || '生成失败,请返回重试'
return
}
if (parsed.status === 'success' && parsed.resultUrl) {
resultUrl.value = parsed.resultUrl
// make/submit 必须用效果图 image_id,禁止回退成 task_id
if (parsed.imageId) {
imageId.value = parsed.imageId
sessionStorage.setItem('douyin_image_id', parsed.imageId)
}
if (fadeTimer) clearTimeout(fadeTimer)
fadeTimer = setTimeout(() => {
if (!handoffDone) enterResultView()
}, 10000)
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
}).catch(() => {})
}
async function beginPendingUpload() {
if (uploading) return
const { file, preview } = takePendingUpload()
if (!file) {
errorTip.value = '未找到待上传图片,请返回重新选择'
return
}
uploading = true
if (preview) sourceUrl.value = preview
tip.value = '上传中'
subTip.value = '请稍候'
progress.value = Math.max(progress.value, 8)
try {
const res = await douyinApi.startGeneratePipeline(file, {
filename: file.name,
content_type: file.type || 'image/jpeg',
style: config.messages.effectTitle,
n: 1,
})
taskId.value = res.taskId
if (res.pid != null) {
pid.value = String(res.pid)
sessionStorage.setItem('douyin_pid', String(res.pid))
}
sessionStorage.setItem('douyin_task_id', res.taskId)
if (res.upload_id) sessionStorage.setItem('douyin_upload_id', res.upload_id)
if (res.file_url) {
sessionStorage.setItem('douyin_file_url', res.file_url)
sourceUrl.value = res.file_url
}
tip.value = '生成中'
subTip.value = '请稍候'
progress.value = Math.max(progress.value, 18)
startMaskFetch()
pollStatus()
pollTimer = setInterval(pollStatus, config.poll.interval)
} catch (err: any) {
const msg = err?.message || '上传失败,请返回重试'
errorTip.value = msg
// 已提交制作:不停留等待页,标记锁定并回首页
if (isSubmittedBizError(msg)) {
sessionStorage.setItem(SUBMITTED_KEY, '1')
showToast(msg)
setTimeout(() => {
router.replace(config.routes.home)
}, 600)
}
} finally {
uploading = false
}
}
function openZoom() {
if (!resultUrl.value) return
showImagePreview({
images: [resultUrl.value],
closeable: true,
closeIconPosition: 'top-right',
})
}
function openConfirmModal() {
if (ordering.value || !taskId.value) return
showConfirmModal.value = true
}
async function confirmSubmit() {
if (ordering.value || !taskId.value) return
ordering.value = true
errorTip.value = ''
try {
const res = await douyinApi.submitProduction({
task_id: taskId.value,
image_id: imageId.value,
})
if (res.imageId) {
imageId.value = res.imageId
sessionStorage.setItem('douyin_image_id', res.imageId)
}
sessionStorage.setItem(SUBMITTED_KEY, '1')
showConfirmModal.value = false
router.replace(config.routes.success)
} catch (err: any) {
errorTip.value = err?.message || '提交失败,请重试'
showConfirmModal.value = false
} finally {
ordering.value = false
}
}
onMounted(() => {
stageSide.value = calcStageSide()
window.addEventListener('resize', onResize)
startSoftProgress()
const pending = route.query.pending === '1'
if (pending) {
beginPendingUpload()
return
}
if (!taskId.value) {
errorTip.value = '缺少任务参数,请返回重新生成'
return
}
const fileUrl = sessionStorage.getItem('douyin_file_url')
if (fileUrl) sourceUrl.value = fileUrl
startMaskFetch()
pollStatus()
pollTimer = setInterval(pollStatus, config.poll.interval)
})
onUnmounted(() => {
maskAborted = true
clearTimers()
window.removeEventListener('resize', onResize)
})
</script>
<style scoped lang="scss">
.gen-page {
position: relative;
min-height: 100vh;
min-height: 100dvh;
background: #000000;
color: #f2f2f2;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
transition: background-color 0.85s cubic-bezier(0.4, 0, 0.2, 1);
}
.gen-page.is-result {
background: #ffffff;
color: #1a1a1a;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.stage-area {
position: relative;
flex: 1 1 auto;
width: 100%;
min-height: 240px;
}
.stage-center {
position: fixed;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
width: max-content;
max-width: calc(100vw - 32px);
}
.stage-shell {
margin: 0 auto;
overflow: hidden;
background: transparent;
}
.stage-box {
position: relative;
overflow: hidden;
background: #000000;
}
.stage-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.stage-layer > :deep(.ipc) {
width: 100% !important;
height: 100% !important;
}
.result-layer {
object-fit: contain;
opacity: 0.35;
z-index: 2;
pointer-events: none;
background: transparent;
}
.error-tip {
margin-top: 12px;
max-width: 280px;
text-align: center;
font-size: 13px;
color: #ff8a8a;
}
.error-tip.light {
color: #e05a5a;
margin: 8px auto 0;
}
.progress-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
padding: 16px 24px calc(20px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.88) 40%);
}
.bar {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.inner {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
transition: width 0.35s ease;
}
.meta {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
.tip {
font-size: 14px;
color: #f2f2f2;
font-weight: 600;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
/* —— 效果预览(设计图) —— */
.result-body {
padding: 4px 18px 24px;
animation: resultIn 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes resultIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.result-head {
margin-bottom: 16px;
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #5fbf45;
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.22);
}
.status-text {
font-size: 13px;
font-weight: 600;
color: #5fbf45;
}
.headline {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #111;
letter-spacing: 0.02em;
line-height: 1.25;
}
.subline {
margin: 6px 0 0;
font-size: 13px;
color: #8a9586;
line-height: 1.4;
}
.preview-card {
position: relative;
width: 100%;
aspect-ratio: 3 / 3.55;
max-height: 58vh;
border-radius: 28px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
}
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.source-polaroid {
position: absolute;
left: 14px;
top: 18%;
width: 72px;
padding: 5px 5px 14px;
background: #fff;
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
transform: rotate(-8deg);
z-index: 2;
pointer-events: none;
}
.source-polaroid img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
border-radius: 2px;
}
.zoom-btn {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 3;
border: none;
border-radius: 999px;
padding: 7px 12px;
background: rgba(40, 44, 48, 0.55);
color: #fff;
font-size: 12px;
backdrop-filter: blur(6px);
}
.save-hint {
margin: 14px 0 0;
text-align: center;
font-size: 12px;
color: #9aa394;
line-height: 1.5;
}
.result-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
gap: 10px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 28%);
box-sizing: border-box;
}
.btn-remake {
flex: 0 0 108px;
height: 48px;
border: none;
border-radius: 24px;
background: #e2eedd;
color: #4cae3a;
font-size: 15px;
font-weight: 600;
}
.btn-submit {
flex: 1;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
color: #fff;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
}
.btn-submit:disabled {
opacity: 0.55;
box-shadow: none;
}
/* 确认弹窗 */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.confirm-sheet {
width: 100%;
max-width: 320px;
border-radius: 18px;
overflow: hidden;
background: #fff;
box-shadow: 6px 8px 0 rgba(0, 0, 0, 0.55);
border: 2px solid #1a1a1a;
}
.confirm-title {
background: #5fbf45;
color: #fff;
text-align: center;
font-size: 17px;
font-weight: 700;
padding: 14px 16px;
}
.confirm-body {
padding: 20px 18px 8px;
text-align: center;
color: #333;
font-size: 14px;
line-height: 1.55;
}
.confirm-sub {
margin-top: 8px;
font-size: 12px;
color: #888;
}
.confirm-actions {
display: flex;
gap: 10px;
padding: 12px 16px 18px;
}
.btn-cancel,
.btn-ok {
flex: 1;
height: 42px;
border-radius: 21px;
font-size: 15px;
font-weight: 600;
}
.btn-cancel {
border: 1.5px solid #8fd96f;
background: #f3faf0;
color: #5fbf45;
}
.btn-ok {
border: none;
background: #5fbf45;
color: #fff;
}
.btn-ok:disabled {
opacity: 0.55;
}
</style>
+468
View File
@@ -0,0 +1,468 @@
<template>
<div class="dy-page">
<DyNavBar
theme="light"
:show-back="false"
right-text="生成记录"
border
@right="goRecords"
>
<template #left>
<div class="brand">
<img class="brand-logo" :src="config.images.logo" alt="">
<span class="brand-name">{{ config.brandName }}</span>
</div>
</template>
<!-- 首页中间不放标题,品牌在左侧 -->
<span />
</DyNavBar>
<div class="content">
<div class="hero">
<img class="hero-img" :src="config.images.logo" alt="">
<div class="hero-title">{{ config.messages.homeTitle }}</div>
<div class="hero-hint">{{ config.messages.homeHint }}</div>
<div class="steps">
<span>选图</span><span class="dot">·</span>
<span>生成</span><span class="dot">·</span>
<span>提交</span>
</div>
</div>
<div class="ref-hint" @click="showEffect = true">
<img class="ref-thumb" :src="config.images.effectRef" alt="">
<div class="ref-body">
<div class="ref-label">{{ config.messages.effectLabel }}</div>
<div class="ref-title">{{ config.messages.effectTitle }}</div>
</div>
<span class="ref-arrow">›</span>
</div>
<div class="select-panel">
<div class="guide">{{ photoPreview ? '确认照片后开始生成' : '添加 1 张清晰正面人像' }}</div>
<div class="photo-slot" @click="triggerPick">
<img v-if="photoPreview" :src="photoPreview" class="photo-preview" alt="">
<div v-else class="empty">
<div class="plus">+</div>
<div>{{ config.messages.addPhoto }}</div>
<div class="sub">相册或拍照</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onFileChange"
>
</div>
</div>
<div class="footer">
<div class="quota" v-if="orderLocked">
{{ lockReason || config.messages.orderSubmitted }}
</div>
<div class="quota" v-else-if="orderStat.remain_count != null">
{{ config.messages.remainCount }}:{{ orderStat.remain_count || 0 }}
</div>
<button
class="btn-primary"
type="button"
:disabled="submitting || orderLocked || buttonDisabled"
@click="onSubmit"
>
{{
orderLocked
? (lockReason === config.messages.noRemain ? config.messages.noRemain : config.messages.orderSubmittedBtn)
: (photoPreview ? config.messages.startGenerate : config.messages.uploadBtn)
}}
</button>
</div>
<div v-if="showEffect" class="effect-mask" @click="showEffect = false">
<img :src="config.images.effectRef" alt="">
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import * as badgeApi from '@/api/badge'
import * as douyinApi from '@/api/douyin'
import { douyinConfig as config } from '@/config/douyin'
import { preloadLogoCloud } from './components/image-point-cloud/logo-cloud-cache'
import DyNavBar from './components/DyNavBar.vue'
import { setPendingUpload } from './pendingUpload'
const SUBMITTED_KEY = 'douyin_order_submitted'
const SUBMITTED_MSG = '本订单号已提交制作,不能继续上传图片或生图'
const router = useRouter()
const fileInput = ref<HTMLInputElement | null>(null)
const photoPreview = ref('')
const photoFile = ref<File | null>(null)
const submitting = ref(false)
const showEffect = ref(false)
const orderStat = ref<any>({})
const orderLocked = ref(false)
const lockReason = ref('')
const checkingLock = ref(false)
const buttonDisabled = computed(() => checkingLock.value)
function isSubmittedBizError(msg: string) {
return /已提交制作|不能继续上传|不能继续生图/.test(String(msg || ''))
}
function markOrderSubmitted(reason?: string) {
orderLocked.value = true
lockReason.value = reason || SUBMITTED_MSG
sessionStorage.setItem(SUBMITTED_KEY, '1')
}
function goRecords() {
router.push(config.routes.records)
}
function triggerPick() {
if (orderLocked.value) {
showToast(lockReason.value || SUBMITTED_MSG)
return
}
fileInput.value?.click()
}
function getOrderStat() {
return badgeApi.getOrderStat({}).then((res: any) => {
orderStat.value = res || {}
// 剩余次数用尽时同样不可再上传生图
if (res && res.remain_count != null && Number(res.remain_count) <= 0) {
orderLocked.value = true
lockReason.value = config.messages.noRemain
}
}).catch(() => {
// 抖音链路可不依赖该接口,失败时不阻断上传
})
}
/** 轻量探测:已提交制作的订单签发上传地址会失败 */
async function probeUploadAllowed() {
if (sessionStorage.getItem(SUBMITTED_KEY) === '1') {
markOrderSubmitted()
return false
}
checkingLock.value = true
try {
await douyinApi.getUploadUrl({
filename: '_probe.jpg',
content_type: 'image/jpeg',
})
return true
} catch (err: any) {
const msg = err?.message || ''
if (isSubmittedBizError(msg)) {
markOrderSubmitted(msg)
return false
}
// 其它错误不在首页拦截,交给后续流程
return true
} finally {
checkingLock.value = false
}
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (file.size > config.upload.maxSize) {
showToast(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`)
return
}
const objectUrl = URL.createObjectURL(file)
const img = new Image()
img.src = objectUrl
img.onload = () => {
const w = img.naturalWidth
const h = img.naturalHeight
URL.revokeObjectURL(objectUrl)
if (w < config.upload.minWidth || h < config.upload.minHeight) {
showToast(`请上传尺寸大于${config.upload.minWidth}×${config.upload.minHeight}像素的照片`)
return
}
photoFile.value = file
const reader = new FileReader()
reader.onload = (ev) => {
photoPreview.value = String(ev.target?.result || '')
}
reader.readAsDataURL(file)
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
showToast('图片加载失败')
}
}
async function onSubmit() {
if (submitting.value || checkingLock.value) return
if (orderLocked.value) {
showToast(lockReason.value || SUBMITTED_MSG)
return
}
if (!photoFile.value || !photoPreview.value) {
showToast(config.messages.pleaseUpload)
return
}
if (orderStat.value.remain_count != null && Number(orderStat.value.remain_count) <= 0) {
showToast(config.messages.noRemain)
return
}
submitting.value = true
const allowed = await probeUploadAllowed()
if (!allowed) {
submitting.value = false
showToast(lockReason.value || SUBMITTED_MSG)
return
}
// 先跳等待页,后台再上传/生图(不弹成功失败 toast)
setPendingUpload(photoFile.value, photoPreview.value)
sessionStorage.setItem('douyin_source_preview', photoPreview.value)
router.push({
path: config.routes.generating,
query: { pending: '1' },
})
submitting.value = false
}
onMounted(async () => {
if (sessionStorage.getItem(SUBMITTED_KEY) === '1') {
markOrderSubmitted()
}
await getOrderStat()
// 进首页即探测,已提交订单直接锁住按钮,避免再进等待页
if (!orderLocked.value) {
await probeUploadAllowed()
}
// 预采样 Logo 点云,进入等待页可立刻上屏
preloadLogoCloud().catch(() => {})
})
</script>
<style scoped lang="scss">
.dy-page {
min-height: 100vh;
background: #ffffff;
padding-bottom: 120px;
box-sizing: border-box;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
padding-left: 4px;
max-width: 160px;
}
.brand-logo {
width: 26px;
height: 26px;
object-fit: contain;
flex-shrink: 0;
}
.brand-name {
font-size: 15px;
font-weight: 600;
color: #222;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content {
padding: 4px 16px 0;
}
.hero {
text-align: center;
padding: 2px 0 20px;
}
.hero-img {
width: 96px;
height: 96px;
object-fit: contain;
}
.hero-title {
font-size: 22px;
font-weight: 700;
color: #1d2b1a;
}
.hero-hint {
margin-top: 8px;
font-size: 13px;
color: #6b7a66;
}
.steps {
margin-top: 12px;
font-size: 12px;
color: #8aa083;
.dot { margin: 0 6px; }
}
.ref-hint {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 14px;
padding: 10px 12px;
margin-bottom: 10px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
}
.ref-thumb {
width: 52px;
height: 52px;
border-radius: 10px;
object-fit: cover;
background: #eef5ea;
}
.ref-label {
font-size: 12px;
color: #8aa083;
}
.ref-title {
margin-top: 2px;
font-size: 15px;
color: #222;
font-weight: 600;
}
.ref-arrow {
margin-left: auto;
color: #999;
font-size: 20px;
}
.select-panel {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
}
.guide {
font-size: 14px;
color: #333;
margin-bottom: 12px;
}
.photo-slot {
width: 100%;
min-height: 220px;
border-radius: 14px;
background: #f5f8f3;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.photo-preview {
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
display: block;
}
.empty {
text-align: center;
color: #7d8f78;
font-size: 14px;
}
.plus {
width: 56px;
height: 56px;
margin: 0 auto 10px;
border-radius: 50%;
border: 2px solid #5fbf45;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
color: #5fbf45;
line-height: 1;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #9aab94;
}
.hidden-input {
display: none;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(8px);
}
.quota {
text-align: center;
font-size: 12px;
color: #6b7a66;
margin-bottom: 8px;
}
.btn-primary {
width: 100%;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
color: #fff;
font-size: 16px;
font-weight: 600;
}
.btn-primary:disabled {
opacity: 0.6;
}
.effect-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 24px;
img {
max-width: 100%;
max-height: 80vh;
border-radius: 12px;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
/** 首页选图后先跳等待页,用内存暂存待上传文件(避免大图塞 sessionStorage) */
let pendingFile: File | null = null
let pendingPreview = ''
export function setPendingUpload(file: File, previewDataUrl: string) {
pendingFile = file
pendingPreview = previewDataUrl || ''
}
export function takePendingUpload() {
const file = pendingFile
const preview = pendingPreview
pendingFile = null
pendingPreview = ''
return { file, preview }
}
export function peekPendingUpload() {
return { file: pendingFile, preview: pendingPreview }
}
+418
View File
@@ -0,0 +1,418 @@
<template>
<div class="preview-page">
<DyNavBar
theme="light"
title="效果预览"
right-text="生图记录"
border
@back="goBack"
@right="goRecords"
/>
<div class="result-body">
<div class="result-head">
<div class="head-main">
<div class="status-row">
<span class="status-dot" />
<span class="status-text">生成完成</span>
</div>
<h1 class="headline">专属效果已就绪</h1>
<p class="subline">确认满意后即可提交制作</p>
</div>
</div>
<div class="preview-card" @click="openZoom">
<div v-if="!imageUrl" class="loading-box">
<van-loading type="spinner" color="#5fbf45" />
<div>加载效果图中...</div>
</div>
<img v-else class="preview-img" :src="imageUrl" alt="效果图">
<button
v-if="imageUrl"
class="zoom-btn"
type="button"
@click.stop="openZoom"
>
点击放大
</button>
</div>
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
</div>
<div class="result-footer">
<button class="btn-remake" type="button" @click="goHome">重新制作</button>
<button
class="btn-submit"
type="button"
:disabled="ordering || !imageUrl || !taskId"
@click="openConfirmModal"
>
满意,提交制作
</button>
</div>
<div v-if="showConfirmModal" class="confirm-mask" @click.self="showConfirmModal = false">
<div class="confirm-sheet">
<div class="confirm-title">确认效果满意</div>
<div class="confirm-body">
<p>请仔细确认效果是否满意后再提交</p>
<p class="confirm-sub">提交制作后将进入制作流程,请确认效果满意后再继续</p>
</div>
<div class="confirm-actions">
<button class="btn-cancel" type="button" @click="showConfirmModal = false">返回查看</button>
<button class="btn-ok" type="button" :disabled="ordering" @click="confirmOrder">
提交制作
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showImagePreview, showSuccessToast, showToast } from 'vant'
import * as douyinApi from '@/api/douyin'
import { douyinConfig as config } from '@/config/douyin'
import DyNavBar from './components/DyNavBar.vue'
const route = useRoute()
const router = useRouter()
const taskId = ref(String(route.query.task_id || sessionStorage.getItem('douyin_task_id') || ''))
const imageId = ref(String(route.query.image_id || sessionStorage.getItem('douyin_image_id') || ''))
const imageUrl = ref(String(route.query.result_url || sessionStorage.getItem('douyin_result_url') || ''))
const ordering = ref(false)
const showConfirmModal = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
function goHome() {
router.replace(config.routes.home)
}
/** 从哪进来回哪去;无历史时回首页 */
function goBack() {
const from = String(route.query.from || sessionStorage.getItem('douyin_preview_from') || '')
if (from === 'records') {
router.replace(config.routes.records)
return
}
if (from === 'home' || from === 'generating') {
router.replace(config.routes.home)
return
}
if (window.history.length > 1) {
router.back()
return
}
router.replace(config.routes.home)
}
function goRecords() {
router.push(config.routes.records)
}
function openZoom() {
if (!imageUrl.value) return
showImagePreview({
images: [imageUrl.value],
closeable: true,
closeIconPosition: 'top-right',
})
}
function openConfirmModal() {
if (ordering.value || !taskId.value) {
showToast('效果图尚未就绪')
return
}
showConfirmModal.value = true
}
function applyStatus(res: any) {
const parsed = douyinApi.parseImageGenStatus(res, taskId.value)
if (parsed.resultUrl) imageUrl.value = parsed.resultUrl
if (parsed.imageId) imageId.value = parsed.imageId
if (parsed.taskId) taskId.value = parsed.taskId
if (parsed.status === 'success' && pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function fetchStatus() {
if (!taskId.value) return
douyinApi.getImageGenStatus(taskId.value).then(applyStatus).catch((err: any) => {
showToast(err.message || '获取效果图失败')
})
}
function confirmOrder() {
if (ordering.value) return
if (!taskId.value) {
showToast('效果图尚未就绪')
return
}
ordering.value = true
douyinApi.submitProduction({
task_id: taskId.value,
image_id: imageId.value,
}).then((res) => {
if (res.imageId) {
imageId.value = res.imageId
sessionStorage.setItem('douyin_image_id', res.imageId)
}
sessionStorage.setItem('douyin_order_submitted', '1')
showConfirmModal.value = false
showSuccessToast({
message: config.messages.orderSuccess,
duration: 1800,
})
setTimeout(() => {
router.replace(config.routes.success)
}, 800)
}).catch((err: any) => {
if (err?.message) showToast(err.message)
}).finally(() => {
ordering.value = false
})
}
onMounted(() => {
if (!taskId.value && !imageUrl.value) {
showToast('缺少预览参数')
goHome()
return
}
if (!imageUrl.value || !imageId.value) {
fetchStatus()
pollTimer = setInterval(fetchStatus, config.poll.interval)
}
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped lang="scss">
.preview-page {
min-height: 100vh;
min-height: 100dvh;
background: #ffffff;
color: #1a1a1a;
box-sizing: border-box;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.result-body {
padding: 4px 18px 24px;
}
.result-head {
margin-bottom: 16px;
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #5fbf45;
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.22);
}
.status-text {
font-size: 13px;
font-weight: 600;
color: #5fbf45;
}
.headline {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #111;
line-height: 1.25;
}
.subline {
margin: 6px 0 0;
font-size: 13px;
color: #8a9586;
}
.preview-card {
position: relative;
width: 100%;
aspect-ratio: 3 / 3.55;
max-height: 58vh;
border-radius: 28px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.loading-box {
text-align: center;
color: #668;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.zoom-btn {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 3;
border: none;
border-radius: 999px;
padding: 7px 12px;
background: rgba(40, 44, 48, 0.55);
color: #fff;
font-size: 12px;
}
.save-hint {
margin: 14px 0 0;
text-align: center;
font-size: 12px;
color: #9aa394;
}
.result-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
gap: 10px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 28%);
}
.btn-remake {
flex: 0 0 108px;
height: 48px;
border: none;
border-radius: 24px;
background: #e2eedd;
color: #4cae3a;
font-size: 15px;
font-weight: 600;
}
.btn-submit {
flex: 1;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
color: #fff;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
}
.btn-submit:disabled {
opacity: 0.55;
box-shadow: none;
}
.confirm-mask {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.confirm-sheet {
width: 100%;
max-width: 320px;
border-radius: 18px;
overflow: hidden;
background: #fff;
box-shadow: 6px 8px 0 rgba(0, 0, 0, 0.55);
border: 2px solid #1a1a1a;
}
.confirm-title {
background: #5fbf45;
color: #fff;
text-align: center;
font-size: 17px;
font-weight: 700;
padding: 14px 16px;
}
.confirm-body {
padding: 20px 18px 8px;
text-align: center;
color: #333;
font-size: 14px;
line-height: 1.55;
}
.confirm-sub {
margin-top: 8px;
font-size: 12px;
color: #888;
}
.confirm-actions {
display: flex;
gap: 10px;
padding: 12px 16px 18px;
}
.btn-cancel,
.btn-ok {
flex: 1;
height: 42px;
border-radius: 21px;
font-size: 15px;
font-weight: 600;
}
.btn-cancel {
border: 1.5px solid #8fd96f;
background: #f3faf0;
color: #5fbf45;
}
.btn-ok {
border: none;
background: #5fbf45;
color: #fff;
}
.btn-ok:disabled {
opacity: 0.55;
}
</style>
+426
View File
@@ -0,0 +1,426 @@
<template>
<div class="records-page">
<DyNavBar
theme="light"
:title="config.records.messages.title"
border
@back="goHome"
/>
<div class="record-container">
<div class="record-note">{{ config.records.messages.note }}</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 首屏骨架屏 -->
<div class="record-images" v-if="showSkeleton">
<div class="record-card is-skeleton" v-for="n in skeletonCount" :key="'sk-' + n">
<div class="skeleton-media"></div>
<div class="skeleton-meta">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</div>
<van-list
v-model:loading="loading"
v-model:error="loadError"
:finished="finished"
:finished-text="images.length ? config.records.messages.noMore : ''"
:loading-text="showSkeleton ? '' : undefined"
:error-text="config.records.messages.loadRetry"
:immediate-check="true"
:offset="120"
@load="onLoad"
>
<div class="record-images" v-if="images.length > 0">
<div
class="record-card"
v-for="item in images"
:key="item.id || item.taskId"
:class="{
'is-ready': isReady(item),
'is-pending': isPending(item),
'is-failed': isFailed(item),
}"
@click="onCardClick(item)"
>
<div class="record-card-media">
<!-- 成功:真实图;加载失败则切到示意图 -->
<template v-if="isReady(item)">
<img
v-if="!isBroken(item)"
:src="item.path"
class="record-image"
alt=""
@error="onImgError(item)"
>
<template v-else>
<img :src="fallbackImage" class="record-image is-fallback" alt="">
<div class="fallback-tip">{{ config.records.messages.imageLoadFailed }}</div>
</template>
</template>
<!-- 生成中 -->
<template v-else-if="isPending(item)">
<div class="record-placeholder">
<van-loading type="spinner" size="22px" color="#5fbf45" />
<p class="placeholder-text">{{ item.reason || config.records.messages.designing }}</p>
</div>
</template>
<!-- 生成失败:示意图占位 -->
<template v-else>
<img :src="fallbackImage" class="record-image is-fallback" alt="">
<div class="fallback-tip">{{ item.reason || config.records.messages.designFailed }}</div>
</template>
</div>
<div class="record-card-meta">
<span class="meta-style">{{ item.style || '效果图' }}</span>
<span class="meta-action" v-if="isReady(item) && !isBroken(item)">查看</span>
</div>
</div>
</div>
<div
v-else-if="!showSkeleton && !loading && finished"
class="record-image-item-null"
>
<img :src="nullDataImage" :alt="config.records.messages.noData">
</div>
</van-list>
</van-pull-refresh>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as douyinApi from '@/api/douyin'
import { douyinConfig as config } from '@/config/douyin'
import nullDataImage from '@/assets/badge/null-data.png'
import DyNavBar from './components/DyNavBar.vue'
const router = useRouter()
const fallbackImage = config.images.effectRef
const skeletonCount = 6
const images = ref<any[]>([])
const page = ref(config.records.pagination.initialPage)
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const loadError = ref(false)
const total = ref(0)
const initialLoaded = ref(false)
/** 防止刷新与触底同时打两次接口 */
const fetching = ref(false)
/** 图片加载失败的 task/id 集合 */
const brokenMap = reactive<Record<string, boolean>>({})
const showSkeleton = computed(() => !initialLoaded.value && images.value.length === 0)
function itemKey(item: any) {
return String(item.id || item.taskId || item.sw_pid || '')
}
function isReady(item: any) {
return item.status == config.records.status.success && !!item.path
}
function isPending(item: any) {
return item.status == config.records.status.designing
}
function isFailed(item: any) {
return !isPending(item) && !isReady(item)
}
function isBroken(item: any) {
return !!brokenMap[itemKey(item)]
}
function onImgError(item: any) {
const key = itemKey(item)
if (!key || brokenMap[key]) return
brokenMap[key] = true
}
function goHome() {
router.push(config.routes.home)
}
async function fetchPage() {
const res: any = await douyinApi.getImageGenRecords({
page: page.value,
page_size: config.records.pagination.pageSize,
})
const normalized = douyinApi.normalizeRecords(res)
total.value = normalized.total
if (page.value === config.records.pagination.initialPage) {
images.value = normalized.list
} else {
images.value.push(...normalized.list)
}
const reachedEnd =
!normalized.list.length ||
images.value.length >= normalized.total ||
normalized.list.length < config.records.pagination.pageSize
if (reachedEnd) {
finished.value = true
} else {
page.value += 1
}
}
/** van-list 触底 / 首次检查时触发 */
async function onLoad() {
if (finished.value || fetching.value) {
loading.value = false
return
}
fetching.value = true
loadError.value = false
try {
await fetchPage()
} catch (err: any) {
console.error(config.records.messages.getDataFailed, err)
loadError.value = true
} finally {
fetching.value = false
loading.value = false
initialLoaded.value = true
}
}
async function onRefresh() {
refreshing.value = true
page.value = config.records.pagination.initialPage
finished.value = false
loadError.value = false
images.value = []
Object.keys(brokenMap).forEach((k) => delete brokenMap[k])
initialLoaded.value = false
// 交给 van-list 重新检查并触发 @load;若未触发则主动拉一页
loading.value = true
await onLoad()
refreshing.value = false
}
function goPreview(item: any) {
sessionStorage.setItem('douyin_task_id', item.taskId || '')
sessionStorage.setItem('douyin_image_id', item.imageId || item.num || '')
sessionStorage.setItem('douyin_result_url', item.path || '')
router.push({
path: config.routes.preview,
query: {
task_id: item.taskId || '',
image_id: item.imageId || item.num || '',
result_url: item.path || '',
},
})
}
function onCardClick(item: any) {
if (isReady(item) && !isBroken(item)) {
goPreview(item)
}
}
onUnmounted(() => {
loading.value = false
finished.value = false
page.value = config.records.pagination.initialPage
})
</script>
<style scoped lang="scss">
.records-page {
min-height: 100vh;
background: #f7f8f6;
}
.record-container {
padding: 12px 16px 24px;
}
.record-note {
font-size: 12px;
color: #999;
margin-bottom: 14px;
line-height: 1.4;
}
.record-images {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
align-items: start;
}
.record-card {
display: flex;
flex-direction: column;
border-radius: 14px;
background: #fff;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 4px 14px rgba(20, 40, 20, 0.06);
transition: transform 0.18s ease, box-shadow 0.18s ease;
&.is-ready {
cursor: pointer;
&:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(20, 40, 20, 0.08);
}
}
}
.record-card-media {
position: relative;
width: 100%;
background: #fff;
overflow: hidden;
line-height: 0;
}
.record-image {
display: block;
width: 100%;
height: auto;
vertical-align: top;
&.is-fallback {
width: 100%;
height: auto;
aspect-ratio: 3 / 4;
object-fit: contain;
object-position: center;
background: #f3f5f2;
}
}
.fallback-tip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 10px;
font-size: 11px;
color: #fff;
text-align: center;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.45));
line-height: 1.3;
pointer-events: none;
}
.record-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 12px;
text-align: center;
width: 100%;
aspect-ratio: 3 / 4;
box-sizing: border-box;
background: linear-gradient(180deg, #eef2ec 0%, #e6ebe4 100%);
line-height: 1.4;
}
.placeholder-text {
margin: 0;
font-size: 12px;
color: #999;
line-height: 1.4;
}
.record-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
min-height: 40px;
box-sizing: border-box;
}
.meta-style {
flex: 1;
min-width: 0;
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-action {
flex-shrink: 0;
font-size: 12px;
color: #5fbf45;
font-weight: 500;
}
/* 骨架屏 */
.is-skeleton {
pointer-events: none;
box-shadow: none;
border-color: transparent;
}
.skeleton-media {
width: 100%;
aspect-ratio: 3 / 4;
background: linear-gradient(90deg, #ecefec 25%, #f5f7f4 37%, #ecefec 63%);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
.skeleton-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
}
.skeleton-line {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #ecefec 25%, #f5f7f4 37%, #ecefec 63%);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
flex: 1;
&.short {
flex: 0 0 36px;
}
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.record-image-item-null {
height: 50vh;
display: flex;
justify-content: center;
align-items: center;
color: #999;
}
.record-image-item-null img {
width: 80vw;
height: 200px;
object-fit: contain;
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<template>
<div class="success-page">
<DyNavBar
theme="light"
title="提交成功"
right-text="生图记录"
border
@back="goHome"
@right="goRecords"
/>
<div class="body">
<div class="hero">
<div class="hero-rings" aria-hidden="true">
<span class="ring r1" />
<span class="ring r2" />
<span class="ring r3" />
</div>
<div class="hero-icon">
<span class="check">✓</span>
</div>
<h1 class="hero-title">提交制作成功</h1>
<p class="hero-desc">效果图已确认并进入制作流程,可随时在生图记录中查看</p>
</div>
<div v-if="resultUrl" class="thumb-card" @click="openZoom">
<img :src="resultUrl" class="thumb-img" alt="已提交效果图">
<div class="thumb-badge">已提交</div>
</div>
<div class="steps-card">
<div class="steps-title">接下来会怎样</div>
<ul class="steps">
<li class="step is-done">
<span class="dot" />
<div>
<div class="step-name">确认效果并提交</div>
<div class="step-desc">已完成</div>
</div>
</li>
<li class="step is-active">
<span class="dot" />
<div>
<div class="step-name">进入制作流程</div>
<div class="step-desc">制作团队将按效果图开始生产</div>
</div>
</li>
<li class="step">
<span class="dot" />
<div>
<div class="step-name">完成后通知发货</div>
<div class="step-desc">请留意订单物流与消息通知</div>
</div>
</li>
</ul>
</div>
<div class="tips-card">
<div class="tips-title">温馨提示</div>
<p>本订单提交后不可再次上传生图,如需查看效果请前往生图记录。</p>
</div>
</div>
<div class="footer">
<button class="btn-ghost" type="button" @click="goHome">返回首页</button>
<button class="btn-primary" type="button" @click="goRecords">查看生成记录</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showImagePreview } from 'vant'
import { douyinConfig as config } from '@/config/douyin'
import DyNavBar from './components/DyNavBar.vue'
const router = useRouter()
const resultUrl = ref(sessionStorage.getItem('douyin_result_url') || '')
function goHome() {
router.replace(config.routes.home)
}
function goRecords() {
router.replace(config.routes.records)
}
function openZoom() {
if (!resultUrl.value) return
showImagePreview({
images: [resultUrl.value],
closeable: true,
closeIconPosition: 'top-right',
})
}
</script>
<style scoped lang="scss">
.success-page {
min-height: 100vh;
min-height: 100dvh;
background: #f7faf6;
box-sizing: border-box;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.body {
padding: 8px 16px 24px;
animation: pageIn 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes pageIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero {
position: relative;
text-align: center;
padding: 28px 12px 20px;
overflow: hidden;
}
.hero-rings {
position: absolute;
left: 50%;
top: 42px;
width: 120px;
height: 120px;
transform: translateX(-50%);
pointer-events: none;
}
.ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(95, 191, 69, 0.28);
animation: pulse 2.4s ease-out infinite;
}
.r2 {
inset: -14px;
animation-delay: 0.35s;
opacity: 0.7;
}
.r3 {
inset: -28px;
animation-delay: 0.7s;
opacity: 0.4;
}
@keyframes pulse {
0% {
transform: scale(0.86);
opacity: 0.55;
}
70% {
transform: scale(1.08);
opacity: 0;
}
100% {
opacity: 0;
}
}
.hero-icon {
position: relative;
z-index: 1;
width: 72px;
height: 72px;
margin: 0 auto 16px;
border-radius: 50%;
background: linear-gradient(145deg, #8fd96f 0%, #5fbf45 100%);
box-shadow: 0 10px 24px rgba(95, 191, 69, 0.35);
display: flex;
align-items: center;
justify-content: center;
animation: pop 0.5s cubic-bezier(0.34, 1.4, 0.64, 1);
}
@keyframes pop {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.check {
color: #fff;
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.hero-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #152016;
letter-spacing: 0.02em;
}
.hero-desc {
margin: 8px auto 0;
max-width: 280px;
font-size: 13px;
line-height: 1.55;
color: #7a8a76;
}
.thumb-card {
position: relative;
margin: 8px auto 14px;
width: min(100%, 220px);
aspect-ratio: 1;
border-radius: 18px;
overflow: hidden;
background: #fff;
box-shadow: 0 8px 22px rgba(40, 70, 50, 0.1);
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: #fff;
}
.thumb-badge {
position: absolute;
left: 10px;
top: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(95, 191, 69, 0.92);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.steps-card,
.tips-card {
background: #fff;
border-radius: 16px;
padding: 16px 16px 14px;
margin-bottom: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.04);
}
.steps-title,
.tips-title {
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 12px;
}
.steps {
list-style: none;
margin: 0;
padding: 0;
}
.step {
position: relative;
display: flex;
gap: 12px;
padding: 0 0 16px;
}
.step:last-child {
padding-bottom: 0;
}
.step:not(:last-child)::before {
content: '';
position: absolute;
left: 7px;
top: 18px;
bottom: 0;
width: 2px;
background: #e6efe3;
}
.step .dot {
flex-shrink: 0;
width: 16px;
height: 16px;
margin-top: 2px;
border-radius: 50%;
background: #d7e4d3;
box-sizing: border-box;
border: 3px solid #eef5eb;
}
.step.is-done .dot,
.step.is-active .dot {
background: #5fbf45;
border-color: rgba(95, 191, 69, 0.22);
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.16);
}
.step-name {
font-size: 14px;
font-weight: 600;
color: #222;
}
.step-desc {
margin-top: 2px;
font-size: 12px;
color: #8a9586;
line-height: 1.4;
}
.tips-card p {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: #6b7a66;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
gap: 10px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(247, 250, 246, 0) 0%, #f7faf6 26%);
box-sizing: border-box;
}
.btn-ghost {
flex: 0 0 112px;
height: 48px;
border: none;
border-radius: 24px;
background: #e4efe0;
color: #3f9e36;
font-size: 15px;
font-weight: 600;
}
.btn-primary {
flex: 1;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
color: #fff;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.32);
}
</style>
+7938
View File
File diff suppressed because it is too large. Load diff