305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
import { ref, reactive, onUnmounted } from 'vue'
|
|
|
|
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
|
|
|
|
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
|
|
|
|
const toAssetUrl = (path) => {
|
|
if (!path) return ''
|
|
if (/^(https?:|blob:|data:)/i.test(path)) return path
|
|
return apiUrl(path)
|
|
}
|
|
|
|
const parseApiError = async (response, fallback) => {
|
|
const text = await response.text().catch(() => '')
|
|
try {
|
|
const parsed = JSON.parse(text)
|
|
return parsed?.detail || parsed?.error || fallback
|
|
} catch {
|
|
return text || fallback
|
|
}
|
|
}
|
|
|
|
const requestJson = async (path, init = {}) => {
|
|
const response = await fetch(apiUrl(path), {
|
|
...init,
|
|
headers: {
|
|
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
|
...(init.headers || {})
|
|
}
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(await parseApiError(response, `${path} ${response.status}`))
|
|
}
|
|
return response.json()
|
|
}
|
|
|
|
const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => {
|
|
const [meta, payload] = dataUrl.split(',')
|
|
const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg'
|
|
const binary = atob(payload || '')
|
|
const bytes = new Uint8Array(binary.length)
|
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
|
return new File([bytes], filename, { type: mime })
|
|
}
|
|
|
|
const imageSourceToFile = async (source, filename = 'reference.jpg') => {
|
|
if (!source) return null
|
|
if (source instanceof File) return source
|
|
if (typeof source !== 'string') return null
|
|
if (source.startsWith('data:')) return dataUrlToFile(source, filename)
|
|
const url = source.startsWith('/jobs/') ? apiUrl(source) : source
|
|
const response = await fetch(url)
|
|
if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
|
|
const blob = await response.blob()
|
|
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
|
}
|
|
|
|
const createCreativeImageJob = async (file = null) => {
|
|
if (file) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return requestJson('/creative/jobs/image', { method: 'POST', body: form })
|
|
}
|
|
return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) })
|
|
}
|
|
|
|
const uploadReferenceFrame = async (jobId, file) => {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
|
|
}
|
|
|
|
const newestGeneratedImage = (job, frameIdx = 0) => {
|
|
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
|
|
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
|
}
|
|
|
|
const newestGeneratedVideo = (job) => (
|
|
[...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
|
)
|
|
|
|
const normalizeVideoSize = (value) => {
|
|
const raw = String(value || '').trim().toLowerCase()
|
|
const map = {
|
|
'9:16': '720x1280',
|
|
'9x16': '720x1280',
|
|
'vertical': '720x1280',
|
|
'portrait': '720x1280',
|
|
'16:9': '1280x720',
|
|
'16x9': '1280x720',
|
|
'horizontal': '1280x720',
|
|
'landscape': '1280x720',
|
|
'1:1': '1024x1024',
|
|
'1x1': '1024x1024',
|
|
'3:4': '960x1280',
|
|
'3x4': '960x1280'
|
|
}
|
|
if (/^\d+x\d+$/.test(raw)) return raw
|
|
return map[raw] || '720x1280'
|
|
}
|
|
|
|
export const useApiState = () => {
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
const status = ref('idle')
|
|
|
|
const reset = () => {
|
|
loading.value = false
|
|
error.value = null
|
|
status.value = 'idle'
|
|
}
|
|
const setLoading = (isLoading) => {
|
|
loading.value = isLoading
|
|
status.value = isLoading ? 'running' : status.value
|
|
}
|
|
const setError = (err) => {
|
|
error.value = err
|
|
status.value = 'error'
|
|
loading.value = false
|
|
}
|
|
const setSuccess = () => {
|
|
status.value = 'success'
|
|
loading.value = false
|
|
error.value = null
|
|
}
|
|
|
|
return { loading, error, status, reset, setLoading, setError, setSuccess }
|
|
}
|
|
|
|
export const useChat = (options = {}) => {
|
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
|
const messages = ref([])
|
|
const currentResponse = ref('')
|
|
let stopped = false
|
|
|
|
const send = async (content) => {
|
|
setLoading(true)
|
|
stopped = false
|
|
try {
|
|
const mode = options.mode || 'chat'
|
|
const response = await requestJson('/prompt/polish', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
text: content,
|
|
system_prompt: options.systemPrompt || '',
|
|
mode,
|
|
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
|
|
})
|
|
})
|
|
const result = response.text || content
|
|
if (!stopped) {
|
|
currentResponse.value = result
|
|
messages.value.push({ role: 'user', content })
|
|
messages.value.push({ role: 'assistant', content: result })
|
|
}
|
|
setSuccess()
|
|
return result
|
|
} catch (err) {
|
|
setError(err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const stop = () => {
|
|
stopped = true
|
|
}
|
|
const clear = () => {
|
|
messages.value = []
|
|
currentResponse.value = ''
|
|
reset()
|
|
}
|
|
onUnmounted(() => stop())
|
|
return { loading, error, status, messages, currentResponse, send, stop, clear, reset }
|
|
}
|
|
|
|
export const useImageGeneration = () => {
|
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
|
const images = ref([])
|
|
const currentImage = ref(null)
|
|
|
|
const generate = async (params) => {
|
|
setLoading(true)
|
|
images.value = []
|
|
currentImage.value = null
|
|
try {
|
|
const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : [])
|
|
const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null
|
|
const job = await createCreativeImageJob(firstRef)
|
|
const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
prompt: params.prompt || '',
|
|
model: params.model || 'auto',
|
|
size: params.size || '1024x1536',
|
|
mode: firstRef ? 'edit' : 'text'
|
|
})
|
|
})
|
|
const generated = newestGeneratedImage(updated, 0)
|
|
if (!generated?.url) throw new Error('图片生成完成但未返回地址')
|
|
const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }]
|
|
images.value = result
|
|
currentImage.value = result[0]
|
|
setSuccess()
|
|
return result
|
|
} catch (err) {
|
|
setError(err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
return { loading, error, status, images, currentImage, generate, reset }
|
|
}
|
|
|
|
export const useVideoGeneration = () => {
|
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
|
const video = ref(null)
|
|
const taskId = ref(null)
|
|
const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 })
|
|
|
|
const createVideoTaskOnly = async (params) => {
|
|
setLoading(true)
|
|
try {
|
|
const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null
|
|
let job = await createCreativeImageJob(firstFile)
|
|
let lastFrameIdx = null
|
|
if (params.last_frame_image) {
|
|
const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg')
|
|
if (lastFile) {
|
|
job = await uploadReferenceFrame(job.id, lastFile)
|
|
lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index))
|
|
}
|
|
}
|
|
const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
prompt: params.prompt || '',
|
|
duration: Number(params.dur || params.duration || params.seconds || 10),
|
|
count: 1,
|
|
first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null,
|
|
last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null,
|
|
model: params.model || 'seedance',
|
|
size: normalizeVideoSize(params.ratio || params.size)
|
|
})
|
|
})
|
|
const created = newestGeneratedVideo(updated)
|
|
if (!created?.id) throw new Error('视频任务已提交但未返回任务编号')
|
|
const id = `skg:${updated.id}:${created.id}`
|
|
taskId.value = id
|
|
status.value = 'polling'
|
|
setSuccess()
|
|
return { taskId: id }
|
|
} catch (err) {
|
|
setError(err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const pollVideoTask = async (pollTaskId, onProgress = () => {}) => {
|
|
const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || ''))
|
|
if (!match) throw new Error('未知视频任务类型')
|
|
const [, jobId, videoId] = match
|
|
const maxAttempts = 180
|
|
const interval = 5000
|
|
|
|
for (let i = 0; i < maxAttempts; i += 1) {
|
|
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
|
|
const item = (job.generated_videos || []).find(v => v.id === videoId)
|
|
if (!item) throw new Error('视频任务不存在')
|
|
const percentage = item.progress || Math.min(Math.round((i / maxAttempts) * 100), 98)
|
|
onProgress(i + 1, percentage)
|
|
progress.attempt = i + 1
|
|
progress.percentage = percentage
|
|
if (item.status === 'completed') {
|
|
const result = { ...item, url: toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) }
|
|
video.value = result
|
|
setSuccess()
|
|
return result
|
|
}
|
|
if (item.status === 'failed') {
|
|
throw new Error(item.error || '视频生成失败')
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, interval))
|
|
}
|
|
throw new Error('视频生成超时')
|
|
}
|
|
|
|
const generate = async (params) => {
|
|
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
|
|
if (url) {
|
|
video.value = { url }
|
|
return video.value
|
|
}
|
|
return pollVideoTask(newTaskId)
|
|
}
|
|
|
|
return { loading, error, status, video, taskId, progress, generate, reset, createVideoTaskOnly, pollVideoTask }
|
|
}
|
|
|
|
export const useApi = () => {
|
|
const chat = useChat()
|
|
const image = useImageGeneration()
|
|
const videoGen = useVideoGeneration()
|
|
return { config: {}, chat, image, video: videoGen }
|
|
}
|