send-stream

This commit is contained in:
wing
2025-11-19 00:12:47 +08:00
parent 7f53203245
commit 33abc33b58
21 changed files with 1630 additions and 2247 deletions

View File

@@ -3,7 +3,7 @@
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
const props = defineProps({
@@ -17,152 +17,73 @@ const props = defineProps({
}
})
// 当前显示的内容(用于打字机效果
const displayedContent = ref('')
// 目标内容(完整内容)
const targetContent = ref('')
// 动画帧 ID
let animationFrameId = null
// 打字机速度(字符/帧,可根据性能调整)
const TYPING_SPEED = 3
// 是否正在执行打字机动画
const isTyping = ref(false)
/**
* 高性能打字机效果渲染
* 使用 requestAnimationFrame 实现平滑的逐字符显示
*/
function typewriterEffect() {
if (!isTyping.value) return
const currentLength = displayedContent.value.length
const targetLength = targetContent.value.length
if (currentLength >= targetLength) {
// 已完成,停止动画
isTyping.value = false
displayedContent.value = targetContent.value
return
}
// 每次增加多个字符以提高性能
const nextLength = Math.min(currentLength + TYPING_SPEED, targetLength)
displayedContent.value = targetContent.value.slice(0, nextLength)
// 继续下一帧
animationFrameId = requestAnimationFrame(typewriterEffect)
}
/**
* 开始打字机效果
*/
function startTypewriter() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
isTyping.value = true
animationFrameId = requestAnimationFrame(typewriterEffect)
}
/**
* 停止打字机效果
*/
function stopTypewriter() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
isTyping.value = false
}
/**
* 计算渲染的 HTML 内容
*/
// 当前渲染的内容(避免重复渲染
const currentContent = ref('')
// 渲染的 HTML 内容
const renderedContent = ref('')
/**
* 更新渲染内容
* 只有当内容真正改变时才更新,避免重复渲染
*/
function updateRenderedContent() {
const content = displayedContent.value
const content = currentContent.value
// 避免重复渲染相同内容
if (content === renderedContent.value.replace(/<[^>]*>/g, '')) {
return
}
if (!content) {
renderedContent.value = ''
return
}
// 渲染 markdown 为 HTML
renderedContent.value = renderMarkdown(content)
}
// 监听 displayedContent 变化,更新渲染
watch(displayedContent, () => {
updateRenderedContent()
}, { immediate: true })
/**
* 处理内容更新
* 普通流式渲染:直接显示所有内容,不使用打字机效果
*/
function handleContentUpdate(newContent) {
if (!newContent) {
targetContent.value = ''
displayedContent.value = ''
stopTypewriter()
currentContent.value = ''
updateRenderedContent()
return
}
// 更新目标内容
targetContent.value = newContent
if (props.isStreaming) {
// 流式传输模式:使用打字机效果显示内容
// 流式传输时,内容会逐步到达,使用打字机效果增强体验
const currentLength = displayedContent.value.length
const newLength = newContent.length
if (newLength !== currentLength) {
// 内容发生变化
if (newLength < currentLength) {
// 内容被重置或缩短,直接显示新内容
displayedContent.value = newContent
stopTypewriter()
} else {
// 内容增加,使用打字机效果显示新增部分
if (!isTyping.value) {
startTypewriter()
}
}
}
} else {
// 静态内容模式:直接显示全部内容,不使用打字机效果
displayedContent.value = newContent
stopTypewriter()
}
// 更新当前内容
currentContent.value = newContent
updateRenderedContent()
}
// 监听 content 变化
// 监听 content 变化,使用防抖处理避免频繁更新
let updateTimeout = null
watch(() => props.content, (newContent) => {
handleContentUpdate(newContent)
}, { immediate: true })
// 清除之前的定时器
if (updateTimeout) {
clearTimeout(updateTimeout)
}
// 延迟更新,避免流式传输时频繁更新导致的性能问题
updateTimeout = setTimeout(() => {
handleContentUpdate(newContent)
}, 50) // 50ms 防抖
})
// 监听 isStreaming 变化
watch(() => props.isStreaming, (newVal, oldVal) => {
if (!newVal && oldVal) {
// 流式传输结束,确保显示完整内容,停止打字机效果
if (targetContent.value) {
displayedContent.value = targetContent.value
}
stopTypewriter()
} else if (newVal) {
// 开始流式传输,如果内容有变化,启动打字机效果
// 打字机效果会在 handleContentUpdate 中根据内容变化自动启动
// 流式传输结束时,确保显示完整内容
if (!newVal && oldVal && props.content) {
currentContent.value = props.content
updateRenderedContent()
}
})
// 组件卸载时清理
onUnmounted(() => {
stopTypewriter()
})
// 立即渲染初始内容
handleContentUpdate(props.content)
</script>
<style scoped>

View File

@@ -55,7 +55,6 @@ const routes = [
]
},
{ path: '/realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') },
{ path: '/mix-editor', name: '素材混剪', component: () => import('../views/mix/MixEditor.vue') },
{ path: '/capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') },
{ path: '/help', name: '帮助', component: () => import('../views/misc/Help.vue') },
{ path: '/download', name: '下载', component: () => import('../views/misc/Download.vue') },

View File

@@ -1,13 +1,13 @@
import { defineStore } from 'pinia'
import storage from '@/utils/storage'
const STORAGE_KEY = 'cosy_voice_profiles'
import { VoiceService } from '@/api/voice'
import { message } from 'ant-design-vue'
export const useVoiceCopyStore = defineStore('voiceCopy', {
state: () => ({
profiles: [],
activeId: '',
loaded: false,
loading: false
}),
getters: {
activeProfile(state) {
@@ -15,48 +15,158 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
}
},
actions: {
generateId() {
return `${Date.now()}_${Math.floor(Math.random() * 1e6)}`
},
/**
* 加载配音列表
*/
async load() {
if (this.loaded) return
const list = await storage.getJSON(STORAGE_KEY, [])
this.profiles = Array.isArray(list) ? list : []
if (!this.activeId && this.profiles.length) this.activeId = this.profiles[0].id
this.loaded = true
},
async persist() {
await storage.setJSON(STORAGE_KEY, this.profiles)
if (this.loaded && !this.loading) return
this.loading = true
try {
const res = await VoiceService.getPage({
pageNo: 1,
pageSize: 100 // 加载所有数据
})
if (res.code === 0) {
this.profiles = (res.data.list || []).map((item) => ({
...item,
voiceId: item.voiceId || '',
transcription: item.transcription || '',
fileUrl: item.fileUrl || ''
}))
// 如果有数据且没有选中项,选中第一个
if (!this.activeId && this.profiles.length > 0) {
this.activeId = this.profiles[0].id
}
this.loaded = true
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载配音列表失败:', error)
message.error('加载失败,请稍后重试')
} finally {
this.loading = false
}
},
/**
* 添加配音
*/
async add(profile) {
const id = this.generateId()
const name = profile.name || `克隆语音-${this.profiles.length + 1}`
const payload = { ...profile, id, name }
this.profiles.unshift(payload)
this.activeId = id
await this.persist()
return payload
try {
const res = await VoiceService.create({
name: profile.name || `克隆语音-${this.profiles.length + 1}`,
fileId: profile.fileId,
autoTranscribe: profile.autoTranscribe || false,
language: profile.language || 'zh-CN',
gender: profile.gender || 'female',
note: profile.note || ''
})
if (res.code === 0) {
const newProfile = {
...profile,
id: res.data
}
this.profiles.unshift(newProfile)
this.activeId = newProfile.id
await this.load() // 重新加载以获取完整数据
return newProfile
} else {
message.error(res.msg || '创建失败')
throw new Error(res.msg || '创建失败')
}
} catch (error) {
console.error('添加配音失败:', error)
throw error
}
},
/**
* 更新配音
*/
async update(profile) {
const idx = this.profiles.findIndex(p => p.id === profile.id)
if (idx === -1) return await this.add({ ...profile, id: '' })
this.profiles[idx] = { ...profile }
await this.persist()
return this.profiles[idx]
if (!profile.id) {
return await this.add(profile)
}
try {
const res = await VoiceService.update({
id: profile.id,
name: profile.name,
language: profile.language,
gender: profile.gender,
note: profile.note
})
if (res.code === 0) {
const index = this.profiles.findIndex(p => p.id === profile.id)
if (index > -1) {
// 重新加载以获取最新数据
await this.load()
const updated = this.profiles.find(p => p.id === profile.id)
return updated || profile
}
return profile
} else {
message.error(res.msg || '更新失败')
throw new Error(res.msg || '更新失败')
}
} catch (error) {
console.error('更新配音失败:', error)
throw error
}
},
/**
* 复制配音
*/
async duplicate(profile, name) {
const copy = { ...profile, id: '', name }
const copy = {
...profile,
id: null,
name: name || `${profile.name}-副本`
}
return await this.add(copy)
},
/**
* 删除配音
*/
async remove(id) {
this.profiles = this.profiles.filter(p => p.id !== id)
if (this.activeId === id) this.activeId = this.profiles[0]?.id || ''
await this.persist()
try {
const res = await VoiceService.delete(id)
if (res.code === 0) {
this.profiles = this.profiles.filter(p => p.id !== id)
if (this.activeId === id) {
this.activeId = this.profiles[0]?.id || ''
}
} else {
message.error(res.msg || '删除失败')
throw new Error(res.msg || '删除失败')
}
} catch (error) {
console.error('删除配音失败:', error)
throw error
}
},
/**
* 选择配音
*/
select(id) {
this.activeId = id
},
/**
* 刷新数据
*/
async refresh() {
this.loaded = false
await this.load()
}
}
})

View File

@@ -60,7 +60,7 @@ export function extractVideoCover(
const url = URL.createObjectURL(file)
video.src = url
let timeoutId: NodeJS.Timeout | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (timeoutId) {

View File

@@ -4,14 +4,14 @@
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">生成数字人</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<section class="bg-white p-4 rounded shadow lg:col-span-1">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<section class="p-4 bg-white rounded shadow lg:col-span-1">
<div class="space-y-3">
<div class="text-gray-600 text-sm">形象背景脚本分辨率字幕等配置</div>
<button class="px-4 py-2 bg-purple-600 text-white rounded">生成视频</button>
<div class="text-sm text-gray-600">形象背景脚本分辨率字幕等配置</div>
<button class="px-4 py-2 text-white bg-purple-600 rounded">生成视频</button>
</div>
</section>
<section class="bg-white p-4 rounded shadow lg:col-span-2">
<section class="p-4 bg-white rounded shadow lg:col-span-2">
<div class="text-gray-500">视频预览任务队列渲染进度</div>
</section>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,14 @@
</template>
上传素材
</a-button>
<a-button
type="primary"
ghost
:disabled="selectedFileIds.length === 0"
@click="handleOpenMixModal"
>
素材混剪
</a-button>
<a-button
v-if="selectedFileIds.length > 0"
type="primary"
@@ -142,11 +150,47 @@
@confirm="handleConfirmUpload"
@cancel="handleUploadCancel"
/>
<a-modal
v-model:open="mixModalVisible"
title="素材混剪"
centered
:confirm-loading="mixing"
ok-text="提交混剪"
cancel-text="取消"
@ok="handleMixConfirm"
@cancel="handleMixCancel"
>
<div class="mix-modal__summary">
<p>选中素材:{{ selectedFiles.length }} 个</p>
<p>视频素材:{{ selectedVideoUrls.length }} 个</p>
<p>背景音乐:{{ selectedAudioUrls.length }} 个</p>
</div>
<a-form layout="vertical">
<a-form-item label="视频标题" required>
<a-input v-model:value="mixForm.title" placeholder="请输入生成视频标题" />
</a-form-item>
<a-form-item label="文案内容" required>
<a-textarea
v-model:value="mixForm.text"
placeholder="请输入文案每句话换行以便自动拆分"
:rows="4"
/>
</a-form-item>
<a-form-item label="生成成片数量" required>
<a-input-number
v-model:value="mixForm.produceCount"
:min="1"
:max="10"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
UploadOutlined,
@@ -154,6 +198,7 @@ import {
FileOutlined
} from '@ant-design/icons-vue'
import { MaterialService } from '@/api/material'
import { MixService } from '@/api/mix'
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
import { formatFileSize, formatDate } from '@/utils/file'
@@ -163,6 +208,8 @@ const fileList = ref([])
const selectedFileIds = ref([])
const uploadModalVisible = ref(false)
const uploading = ref(false)
const mixModalVisible = ref(false)
const mixing = ref(false)
// 筛选条件
const filters = reactive({
@@ -370,6 +417,111 @@ const handleImageError = (e) => {
img.style.display = 'none'
}
const selectedFiles = computed(() =>
fileList.value.filter((file) => selectedFileIds.value.includes(file.id))
)
const isVideoFile = (file) => {
if (!file) return false
if (file.isVideo) return true
if (file.fileCategory === 'video') return true
if (typeof file.fileType === 'string' && file.fileType.startsWith('video')) return true
return false
}
const isAudioFile = (file) => {
if (!file) return false
if (file.fileCategory === 'audio') return true
if (file.fileType === 'audio') return true
if (typeof file.fileType === 'string' && file.fileType.startsWith('audio')) return true
return false
}
const selectedVideoUrls = computed(() =>
selectedFiles.value.map((file) => (isVideoFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
)
const selectedAudioUrls = computed(() =>
selectedFiles.value.map((file) => (isAudioFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
)
const mixForm = reactive({
title: '',
text: '',
produceCount: 1
})
const resetMixForm = () => {
mixForm.title = ''
mixForm.text = ''
mixForm.produceCount = 1
}
const handleOpenMixModal = () => {
if (selectedFileIds.value.length === 0) {
message.warning('请先选择至少一个素材')
return
}
if (selectedVideoUrls.value.length === 0) {
message.warning('请至少选择一个视频素材')
return
}
if (selectedAudioUrls.value.length === 0) {
message.warning('请至少选择一个背景音乐素材')
return
}
mixModalVisible.value = true
}
const handleMixCancel = () => {
mixModalVisible.value = false
}
const handleMixConfirm = async () => {
const title = mixForm.title.trim()
const text = mixForm.text.trim()
if (!title) {
message.warning('请输入视频标题')
return
}
if (!text) {
message.warning('请输入文案内容')
return
}
const produceCount = Math.max(1, Math.min(10, Number(mixForm.produceCount) || 1))
if (selectedVideoUrls.value.length === 0) {
message.warning('请至少选择一个视频素材')
return
}
if (selectedAudioUrls.value.length === 0) {
message.warning('请至少选择一个背景音乐素材')
return
}
mixing.value = true
try {
const { data } = await MixService.batchProduceAlignment({
title,
text,
videoUrls: selectedVideoUrls.value,
bgMusicUrls: selectedAudioUrls.value,
produceCount
})
const jobIds = Array.isArray(data) ? data : []
message.success(
jobIds.length > 0
? `混剪任务提交成功JobId${jobIds.join(', ')}`
: '混剪任务提交成功'
)
mixModalVisible.value = false
resetMixForm()
} catch (error) {
console.error('混剪失败:', error)
message.error(error?.message || '混剪任务提交失败,请重试')
} finally {
mixing.value = false
}
}
// 初始化
onMounted(() => {
loadFileList()
@@ -524,5 +676,20 @@ onMounted(() => {
color: var(--color-text-3);
}
.mix-modal__summary {
margin-bottom: 16px;
padding: 12px;
background: var(--color-bg-2);
border: 1px dashed var(--color-border);
border-radius: var(--radius-card);
font-size: 13px;
color: var(--color-text-2);
}
.mix-modal__summary p {
margin: 0;
line-height: 1.6;
}
</style>

View File

@@ -1,22 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">素材混剪</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<section class="bg-white p-4 rounded shadow lg:col-span-1">
<div class="text-gray-600 text-sm">文案拆解与镜头建议</div>
</section>
<section class="bg-white p-4 rounded shadow lg:col-span-2">
<div class="text-gray-500">素材匹配与时间线导出到剪映</div>
</section>
</div>
</div>
</template>
<style scoped>
</style>