topfans/frontend/composables/useLaserBatchGenerate.js
Lenticular Studio Agent af7908e72e feat: 接入微达API中转站,重构镭射卡生图流程
- 替换中转站从 xbcl.link 到 weda.cc
- prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖)
- 4 路并发调用 + 原图展示 = 5 张 variant
- 前端提示词中译英支持
- 全局 Vue errorHandler
- WebSocket 鉴权失败跳登录
- 删除已弃用的 laserCompositor 微服务

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 22:43:49 +08:00

188 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 镭射卡五图批量生成Phase 1纯客户端 Canvas无后端接口
*/
import { ref } from 'vue'
import {
CASTLOVE_FORM_KEY,
consumeGenerationFlowPayload,
persistLaserPreviewImages,
} from '@/utils/castloveGenerationFlow.js'
import { generateLaserVariantBatch } from '@/utils/laser-card/laserBatchExport.js'
import { segmentPortrait } from '@/composables/useLaserSegment.js'
import { useLaserDifyGenerate } from '@/composables/useLaserDifyGenerate.js'
const DEFAULT_CANVAS_ID = 'laserBatchCanvas'
const DEFAULT_MIN_DURATION_MS = 1600
/**
* @param {Object} [options]
* @param {string} [options.canvasId]
* @param {number} [options.minDurationMs]
* @param {number} [options.flowStartedAt] 页面 onMounted 时的时间戳,用于最短展示时长
*/
export function useLaserBatchGenerate(options = {}) {
const progress = ref(0)
const running = ref(false)
const error = ref(null)
let progressTimer = null
let flowStartedAt = options.flowStartedAt ?? Date.now()
const canvasId = options.canvasId || DEFAULT_CANVAS_ID
const minDurationMs = options.minDurationMs ?? DEFAULT_MIN_DURATION_MS
const stopProgress = () => {
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
const simulateProgress = () => {
stopProgress()
progressTimer = setInterval(() => {
if (progress.value < 90) {
const increment = Math.random() * 5 + 2
progress.value = Math.min(90, progress.value + increment)
}
}, 500)
}
const completeProgress = () =>
new Promise((resolve) => {
stopProgress()
const finalInterval = setInterval(() => {
if (progress.value < 100) {
progress.value = Math.min(100, progress.value + 5)
} else {
clearInterval(finalInterval)
setTimeout(resolve, 500)
}
}, 50)
})
const waitMinDuration = async (minMs) => {
if (!minMs || minMs <= 0) return
const elapsed = Date.now() - flowStartedAt
if (elapsed < minMs) {
await new Promise((r) => setTimeout(r, minMs - elapsed))
}
}
const readFormData = () => {
const formStr = uni.getStorageSync(CASTLOVE_FORM_KEY)
if (!formStr) {
throw new Error('缺少表单数据')
}
return JSON.parse(formStr)
}
const resolveImagePath = (formData) => {
const imagePath = formData?.image || formData?.uploadedImage || ''
if (!imagePath) {
throw new Error('缺少上传图片')
}
return imagePath
}
/**
* 执行五图合成并写入 Storage
* @param {Object} [runOptions]
* @param {Object} [runOptions.flow] consumeGenerationFlowPayload 结果;缺省则自动 consume
* @returns {Promise<string[]>} 本地临时 JPG 路径列表
*/
const run = async (runOptions = {}) => {
if (running.value) {
throw new Error('生成进行中')
}
running.value = true
error.value = null
simulateProgress()
try {
const flow = runOptions.flow ?? consumeGenerationFlowPayload()
const minMs = flow?.minDurationMs ?? minDurationMs
const formData = readFormData()
const imagePath = resolveImagePath(formData)
// Dify 模式:服务端 AI 生成
// genMode: 'dify' | 'openai' | 'client'
// - dify: 调 Dify laser_card_variants_v1 工作流(后端 LASER_GEN_PROVIDER=dify)
// - openai: 调 OpenAI /v1/images/edits(后端 LASER_GEN_PROVIDER=openai,5 路并发 + 直接落 OSS)
// - client: 纯客户端 Canvas 合成
// 前端不感知 provider,只调 /api/v1/laser/generate,由后端按 env 路由
const genMode = 'openai'
if (genMode === 'dify' || genMode === 'openai') {
const dify = useLaserDifyGenerate()
// 表单实际存的字段是 aiDescription为兼容旧调用方runOptions.userPrompt / formData.userPrompt同时 fallback
const userPrompt = (runOptions.userPrompt || formData?.aiDescription || formData?.userPrompt || '').trim()
// 上传到 OSS 获取可访问 URL供中转站下载原图
let imageUrlForAI = imagePath
try {
const uploadResult = await segmentPortrait(imagePath, {
imageBase64: formData.imageBase64 || formData.uploadedImageBase64 || '',
})
imageUrlForAI = uploadResult?.originalUrl || uploadResult?.cutoutUrl || imagePath
} catch (segErr) {
console.warn('[useLaserBatchGenerate] 上传 OSS 失败:', segErr)
throw segErr
}
await dify.submit(imageUrlForAI, null, userPrompt)
const infos = dify.getVariantInfos()
const instanceNoVal = dify.getInstanceNo()
await waitMinDuration(minMs)
persistLaserPreviewImages(infos, instanceNoVal)
await completeProgress()
return infos
}
// Client 模式:本地 Canvas 合成
// 人像抠图 → 透明 PNG 叠在底纹之上,再合成五图
const cutout = await segmentPortrait(imagePath, {
imageBase64: formData.imageBase64 || formData.uploadedImageBase64 || '',
})
// 如果抠图成功,更新 formData 中的 cutoutImage
if (cutout?.localPath) {
formData.cutoutImage = cutout.localPath
}
const paths = await generateLaserVariantBatch(cutout.localPath, canvasId)
const infos = paths.map((url) => ({ url, oss_key: '' }))
await waitMinDuration(minMs)
persistLaserPreviewImages(infos)
await completeProgress()
return infos
} catch (e) {
error.value = e
stopProgress()
throw e
} finally {
running.value = false
}
}
const resetProgress = () => {
stopProgress()
progress.value = 0
}
const setFlowStartedAt = (ts) => {
flowStartedAt = ts
}
return {
progress,
running,
error,
simulateProgress,
completeProgress,
stopProgress,
resetProgress,
setFlowStartedAt,
run,
}
}