diff --git a/src/main/doubao.ts b/src/main/doubao.ts index 07202a1..98a0ec9 100644 --- a/src/main/doubao.ts +++ b/src/main/doubao.ts @@ -384,18 +384,17 @@ export async function createVideoTask( const content: Record[] = [] const isV2 = model.includes('2-0') || model.includes('2.0') - // 首帧图:场景图优先,否则用第一个角色图 - const frameUrl = firstFrameUrl || (refUrls && refUrls.length > 0 ? refUrls[0] : '') - if (frameUrl) { - const resolved = resolveImageUrl(frameUrl) + // 首帧:仅场景图(让视频从该场景开始),不填则 AI 自由生成 + if (firstFrameUrl) { + const resolved = resolveImageUrl(firstFrameUrl) if (resolved) content.push({ type: 'image_url', image_url: { url: resolved }, role: 'first_frame' }) } - // 参考图:仅 2.0 支持 + // 角色参考图:仅 2.0 支持 reference_image,告诉 AI 角色长啥样 if (isV2 && refUrls) { for (const refUrl of refUrls) { const resolved = resolveImageUrl(refUrl) - if (resolved && resolved !== resolveImageUrl(frameUrl)) { + if (resolved) { content.push({ type: 'image_url', image_url: { url: resolved }, role: 'reference_image' }) } } @@ -406,7 +405,7 @@ export async function createVideoTask( const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, - body: JSON.stringify({ model, content, duration: durationSec, resolution, generate_audio: false }) + body: JSON.stringify({ model, content, duration: durationSec, resolution, generate_audio: false, return_last_frame: true }) }) if (!response.ok) { @@ -434,6 +433,7 @@ export async function pollVideoTask(taskId: string): Promise<{ status: string; v return { status: result.status, videoUrl: result.content?.video_url, + lastFrameUrl: result.last_frame_url || '', duration: result.duration, resolution: result.resolution } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 59878d6..f2a1837 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -2,8 +2,8 @@ import { ipcMain } from 'electron' import { getApiKeys, getUiApiKeys, getConfigFileApiKeys, setApiKey, deleteApiKey, openConfigFile, getConfigPath, getEndpoints, - getCharacters, saveCharacter, deleteCharacter, - getScenes, saveScene, deleteScene, + getCharacters, saveCharacter, deleteCharacter, renameCharacter, + getScenes, saveScene, deleteScene, renameScene, getDataDir, getImagesDir, setDataDir, openFolder, pickFolder, getSessions, getSession, saveSession, deleteSession, downloadImage, readImageBase64, imagesDir @@ -49,6 +49,7 @@ export function registerIpcHandlers(): void { }) }) ipcMain.handle('characters:delete', (_e, id) => wrap(() => { deleteCharacter(id); return { success: true } })) + ipcMain.handle('characters:rename', (_e, id, newName) => wrap(() => { renameCharacter(id, newName); return { success: true } })) // === 场景库 === ipcMain.handle('scenes:getAll', () => wrap(() => getScenes())) @@ -61,6 +62,7 @@ export function registerIpcHandlers(): void { }) }) ipcMain.handle('scenes:delete', (_e, id) => wrap(() => { deleteScene(id); return { success: true } })) + ipcMain.handle('scenes:rename', (_e, id, newName) => wrap(() => { renameScene(id, newName); return { success: true } })) // === 图片 === ipcMain.handle('image:readBase64', (_e, path: string) => { diff --git a/src/main/store.ts b/src/main/store.ts index d9976a0..b51b323 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -208,6 +208,15 @@ export function deleteCharacter(id: string): void { writeInternal(data) } +export function renameCharacter(id: string, newName: string): void { + const data = readInternal() + const chars = data.characters || [] + if (chars.some(c => c.name === newName && c.id !== id)) throw new Error('角色「' + newName + '」已存在') + const c = chars.find(c => c.id === id) + if (c) c.name = newName + writeInternal(data) +} + // ===== 场景库 ===== export function getScenes(): StoredScene[] { @@ -231,6 +240,15 @@ export function deleteScene(id: string): void { writeInternal(data) } +export function renameScene(id: string, newName: string): void { + const data = readInternal() + const sc = data.scenes || [] + if (sc.some(s => s.name === newName && s.id !== id)) throw new Error('场景「' + newName + '」已存在') + const s = sc.find(s => s.id === id) + if (s) s.name = newName + writeInternal(data) +} + export function getDataDir(): string { return baseDir() } @@ -362,7 +380,7 @@ export function readImageBase64(localPath: string): string { // ===== 会话管理 ===== export function getSessions(): ChatSession[] { - return readInternal().sessions || [] + return (readInternal().sessions || []).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) } export function getSession(id: string): ChatSession | undefined { diff --git a/src/preload/index.ts b/src/preload/index.ts index 2fde3dd..d35c979 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,11 +22,13 @@ contextBridge.exposeInMainWorld('electronAPI', { getCharacters: () => ipcRenderer.invoke('characters:getAll'), saveCharacter: (c) => ipcRenderer.invoke('characters:save', c), deleteCharacter: (id) => ipcRenderer.invoke('characters:delete', id), + renameCharacter: (id, newName) => ipcRenderer.invoke('characters:rename', id, newName), // 场景库 getScenes: () => ipcRenderer.invoke('scenes:getAll'), saveScene: (s) => ipcRenderer.invoke('scenes:save', s), deleteScene: (id) => ipcRenderer.invoke('scenes:delete', id), + renameScene: (id, newName) => ipcRenderer.invoke('scenes:rename', id, newName), // 图片 readImageBase64: (path: string) => ipcRenderer.invoke('image:readBase64', path), diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index c0153b4..e507bc1 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -60,10 +60,12 @@ interface ElectronAPI { getCharacters: () => Promise saveCharacter: (char: StoredCharacter) => Promise deleteCharacter: (id: string) => Promise + renameCharacter: (id: string, newName: string) => Promise getScenes: () => Promise saveScene: (scene: StoredScene) => Promise deleteScene: (id: string) => Promise + renameScene: (id: string, newName: string) => Promise readImageBase64: (path: string) => Promise @@ -77,7 +79,7 @@ interface ElectronAPI { splitNovel: (novelText: string) => Promise<{ title: string; content: string }[]> generateScript: (novelText: string, duration: number, style?: string, model?: string) => Promise createVideo: (prompt: string, duration: number, resolution: string, model?: string, firstFrameUrl?: string, refUrls?: string[]) => Promise - pollVideo: (taskId: string) => Promise<{ status: string; videoUrl?: string; duration?: number; resolution?: string }> + pollVideo: (taskId: string) => Promise<{ status: string; videoUrl?: string; lastFrameUrl?: string; duration?: number; resolution?: string }> } interface Window { diff --git a/src/renderer/src/views/CharactersPage.vue b/src/renderer/src/views/CharactersPage.vue index a20f444..3052d0f 100644 --- a/src/renderer/src/views/CharactersPage.vue +++ b/src/renderer/src/views/CharactersPage.vue @@ -32,6 +32,7 @@

{{ previewDesc(char.description) }}

@@ -105,6 +106,21 @@ async function load() { } } +async function handleRename(char: StoredCharacter) { + try { + const r = await ElMessageBox.prompt('新名称', '重命名角色', { inputValue: char.name, confirmButtonText: '确定' }) + const name = r.value.trim() + if (!name || name === char.name) return + await window.electronAPI.renameCharacter(char.id, name) + char.name = name + ElMessage.success('已重命名') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '' + if (msg.includes('已存在')) ElMessage.warning(msg) + else if (msg && !msg.includes('cancel')) ElMessage.error('重命名失败') + } +} + async function handleDelete(char: StoredCharacter) { try { await ElMessageBox.confirm(`确定删除「${char.name}」?`, '确认删除') diff --git a/src/renderer/src/views/HomePage.vue b/src/renderer/src/views/HomePage.vue index 40d8377..b2341be 100644 --- a/src/renderer/src/views/HomePage.vue +++ b/src/renderer/src/views/HomePage.vue @@ -127,6 +127,17 @@ + +
+
+
+ 视频任务: {{ msg.videoTaskId }} +
+
@@ -174,13 +185,6 @@
- -
-
@@ -251,6 +255,8 @@ interface Message { text: string imageUrl?: string saved?: boolean + videoTaskId?: string + videoUrl?: string } // ===== 状态 ===== @@ -270,7 +276,7 @@ const generatingVideo = ref(false) const videoTaskId = ref('') const videoResolution = ref('720p') const videoModel = ref('doubao-seedance-2-0-fast-260128') -const videoResult = ref<{ videoUrl?: string } | null>(null) +const videoResult = ref<{ videoUrl?: string; lastFrameUrl?: string } | null>(null) const selectedSceneImage = ref('') const mappedCharacter = ref('') const characterList = ref([]) @@ -325,9 +331,12 @@ const suggestions = [ // ===== 会话管理 ===== let autoSaveTimer: ReturnType | null = null -/** 生成会话标题(取第一条用户消息) */ +/** 生成会话标题(加序号区分同名) */ function makeTitle(text: string): string { - return text.length > 30 ? text.slice(0, 30) + '...' : text + const base = text.length > 30 ? text.slice(0, 30) + '...' : text + // 统计同名会话数,追加序号 + const sameCount = sessions.value.filter(s => s.title.startsWith(base)).length + 1 + return sameCount > 1 ? base + ` (#${sameCount})` : base } /** 格式化会话时间显示 */ @@ -335,8 +344,7 @@ function formatSessionTime(iso: string): string { const d = new Date(iso) const now = new Date() const pad = (n: number) => String(n).padStart(2, '0') - const time = `${pad(d.getHours())}:${pad(d.getMinutes())}` - // 今天的只显示时间,非今天的加日期 + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` if (d.toDateString() === now.toDateString()) return time return `${d.getMonth() + 1}/${d.getDate()} ${time}` } @@ -358,13 +366,28 @@ async function switchSession(id: string) { } if (id === currentSessionId.value) return currentSessionId.value = id - const s = sessions.value.find(x => x.id === id) + // 从后端重新加载最新数据 + const all = await window.electronAPI.getSessions() + sessions.value = all + const s = all.find(x => x.id === id) if (s) { messages.value = s.messages.map(m => ({ ...m })) mode.value = s.mode as Mode currentStyle.value = s.style || 'anime' chatModel.value = s.chatModel imageModel.value = s.imageModel + // 恢复视频状态 + videoResult.value = null + videoTaskId.value = '' + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg?.videoUrl) { + videoResult.value = { videoUrl: lastMsg.videoUrl, lastFrameUrl: (lastMsg as Record).lastFrameUrl as string || '' } + videoTaskId.value = lastMsg.videoTaskId || '' + } else if (lastMsg?.videoTaskId) { + // 有任务 ID 但没 URL,自动查询状态 + videoTaskId.value = lastMsg.videoTaskId + await pollVideoTask(lastMsg) + } } await scrollToBottom() } @@ -388,27 +411,33 @@ async function handleDeleteSession(s: ChatSession) { async function saveCurrentSession(options?: { title?: string }): Promise { if (messages.value.length === 0) return - // 从第一条用户消息取标题 const firstUser = messages.value.find(m => m.role === 'user') const title = options?.title || (firstUser ? makeTitle(firstUser.text) : '新会话') const now = new Date().toISOString() + const sid = currentSessionId.value || (Date.now().toString(36) + Math.random().toString(36).slice(2, 10)) + const existing = sessions.value.find(s => s.id === sid) + const msgCount = messages.value.length + + // 内容没变不更新时间(避免浏览时乱排) + const shouldUpdate = !existing || existing.messages.length !== msgCount + const updatedAt = shouldUpdate ? now : (existing?.updatedAt || now) + const session: ChatSession = { - id: currentSessionId.value || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)), + id: sid, title, mode: mode.value, style: currentStyle.value, chatModel: chatModel.value, imageModel: imageModel.value, messages: JSON.parse(JSON.stringify(messages.value)), - createdAt: currentSessionId.value ? (sessions.value.find(s => s.id === currentSessionId.value)?.createdAt || now) : now, - updatedAt: now + createdAt: existing?.createdAt || now, + updatedAt } await window.electronAPI.saveSession(session) currentSessionId.value = session.id - - // 刷新会话列表 + // 每次保存都从磁盘重载,保证数据一致 sessions.value = await window.electronAPI.getSessions() return session.id } @@ -430,7 +459,7 @@ async function sendMessage() { // 如果是新会话的第一条消息,创建会话并生成标题 if (messages.value.length === 0) { // 先保存一个空会话,让用户能切走 - const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 10) currentSessionId.value = tempId await window.electronAPI.saveSession({ id: tempId, @@ -561,26 +590,36 @@ async function startVideoGeneration(msg: Message) { generatingVideo.value = true videoResult.value = null + const sid = currentSessionId.value // 记录当前会话,防止切换后污染 try { videoTaskId.value = await window.electronAPI.createVideo(prompt, videoDuration.value, videoResolution.value, videoModel.value, firstFrameUrl, refUrls) + msg.videoTaskId = videoTaskId.value + // 立刻存 taskId + await saveCurrentSession() ElMessage.success('视频任务已创建: ' + videoTaskId.value) - await pollForVideo() + await pollForVideo(sid, msg) } catch (e: unknown) { ElMessage.error(e instanceof Error ? e.message : '视频生成失败') } finally { generatingVideo.value = false + if (currentSessionId.value === sid) await saveCurrentSession() } } -async function pollForVideo() { +async function pollForVideo(sessionId: string, msg: Message) { if (!videoTaskId.value) return const maxPolls = 40 for (let i = 0; i < maxPolls; i++) { await new Promise(r => setTimeout(r, 5000)) + if (currentSessionId.value !== sessionId) return try { const result = await window.electronAPI.pollVideo(videoTaskId.value) if (result.status === 'succeeded') { - videoResult.value = result + if (currentSessionId.value === sessionId) videoResult.value = result + // 立即存到消息和会话,绝不丢失 + msg.videoUrl = result.videoUrl + msg.videoTaskId = videoTaskId.value + await saveCurrentSession() ElMessage.success('视频生成完成!') return } @@ -590,7 +629,7 @@ async function pollForVideo() { } } catch { /* retry */ } } - ElMessage.warning('视频生成超时,请稍后查询') + if (currentSessionId.value === sessionId) ElMessage.warning('视频生成超时,请稍后查询') } async function checkVideoStatus() { @@ -608,19 +647,50 @@ async function checkVideoStatus() { } } +/** 从已保存的 taskId 恢复视频 URL */ +async function pollVideoTask(msg: Message) { + if (!msg.videoTaskId) return + try { + const result = await window.electronAPI.pollVideo(msg.videoTaskId) + if (result.status === 'succeeded' && result.videoUrl) { + msg.videoUrl = result.videoUrl + if (result.lastFrameUrl) (msg as Record).lastFrameUrl = result.lastFrameUrl + videoResult.value = { videoUrl: result.videoUrl, lastFrameUrl: result.lastFrameUrl } + await saveCurrentSession() + } + } catch { /* 稍后重试 */ } +} + async function saveLastFrame(videoUrl: string) { try { - const name = '视频尾帧_' + Date.now().toString(36) - await window.electronAPI.saveScene({ - id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), - name, - description: '视频生成尾帧 | 任务ID: ' + videoTaskId.value, - imageUrl: videoUrl + '?return_last_frame=true', - createdAt: new Date().toISOString() - }) - ElMessage.success('尾帧已保存到场景库') + let lfUrl = videoResult.value?.lastFrameUrl || '' + // 没尾帧 URL,尝试从任务查询 + if (!lfUrl && videoTaskId.value) { + try { + const r = await window.electronAPI.pollVideo(videoTaskId.value) + lfUrl = r.lastFrameUrl || '' + if (lfUrl && videoResult.value) videoResult.value.lastFrameUrl = lfUrl + } catch {} + } + if (lfUrl) { + let name = '视频尾帧_' + Date.now().toString(36) + try { + const r = await ElMessageBox.prompt('给尾帧起个名字', '保存尾帧', { inputValue: name, confirmButtonText: '保存' }) + if (r.value.trim()) name = r.value.trim() + } catch { return } + await window.electronAPI.saveScene({ + id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), + name, + description: '视频生成尾帧 | 任务ID: ' + videoTaskId.value, + imageUrl: lfUrl, + createdAt: new Date().toISOString() + }) + ElMessage.success('尾帧已保存到场景库') + } else { + ElMessage.warning('此视频未生成尾帧,请等待下次视频完成后再保存') + } } catch (e: unknown) { - ElMessage.error('保存失败,请尝试手动截图') + ElMessage.error('保存失败: ' + (e instanceof Error ? e.message : '')) } } diff --git a/src/renderer/src/views/ScenesPage.vue b/src/renderer/src/views/ScenesPage.vue index 20d01db..d3a49bf 100644 --- a/src/renderer/src/views/ScenesPage.vue +++ b/src/renderer/src/views/ScenesPage.vue @@ -27,6 +27,7 @@

{{ previewDesc(scene.description) }}

@@ -96,6 +97,21 @@ async function load() { } } +async function handleRename(scene: StoredScene) { + try { + const r = await ElMessageBox.prompt('新名称', '重命名场景', { inputValue: scene.name, confirmButtonText: '确定' }) + const name = r.value.trim() + if (!name || name === scene.name) return + await window.electronAPI.renameScene(scene.id, name) + scene.name = name + ElMessage.success('已重命名') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '' + if (msg.includes('已存在')) ElMessage.warning(msg) + else if (msg && !msg.includes('cancel')) ElMessage.error('重命名失败') + } +} + async function handleDelete(scene: StoredScene) { try { await ElMessageBox.confirm(`确定删除「${scene.name}」?`, '确认删除')