文生视频 Sora2
输入文本描述,生成高质量短剧素材。
方向
时长
实时预览
生成的视频将在此播放
连接中...
Initializing...
提示:生成后的视频会自动归档至“任务记录中心”,您可以在那里将其截取为角色卡。
图生视频 i2v
上传参考图,让画面动起来。
1. 上传首帧/参考图
{Utils.showToast('视频加载失败,但已保存到任务记录', 'error');
});
}
if (!isCurrentTab) {
Utils.showToast('视频生成完成,可在任务记录中心查看', 'success');
}
} catch(e) {TaskModule.update(taskId, {
status: 'failed',
errorMessage: e.message || '生成失败'
});
if (isCurrentTab) {
Utils.showError("生成失败", e.message);
const hasOtherGenerating = TaskModule.tasks.some(t => t.id !== taskId && t.status === 'generating');
if (!hasOtherGenerating) {
load.classList.add('hidden');
}
}
}
}, taskId);
const updateButtonStatus = () => {
const running = TaskQueue.getRunningCount();
const queued = TaskQueue.getQueueCount();
if (btn) {
if (running >= TaskQueue.maxConcurrent) {
btn.title = `当前有 ${running} 个任务正在生成,${queued} 个任务等待中`;
} else {
btn.title = `当前有 ${running} 个任务正在生成,还可以生成 ${TaskQueue.maxConcurrent - running} 个`;
}
}
};
updateButtonStatus();
const statusInterval = setInterval(() => {
updateButtonStatus();
if (TaskQueue.getRunningCount() === 0 && TaskQueue.getQueueCount() === 0) {
clearInterval(statusInterval);
if (isCurrentTab && load && !vid.classList.contains('hidden')) {
load.classList.add('hidden');
}
}
}, 1000);
}
};
const Img2VideoModule = {
styles: [
{ name: "镜头推进", prompt: "缓慢镜头推进" }, { name: "水平摇摄", prompt: "水平向右摇摄" },
{ name: "微动特效", prompt: "微动特效,风吹效果" }, { name: "人物转身", prompt: "人物缓慢转身" }
],
init: () => {
const c = document.getElementById('img2vidStyleContainer');
if(c) {
Img2VideoModule.styles.forEach(s => {
const span = document.createElement('span'); span.className = 'style-chip bg-white px-3 py-1 rounded-full text-xs text-gray-600 border border-gray-200';
span.innerText = s.name; span.onclick = () => Utils.appendStyle('img2vidPrompt', s.prompt);
c.appendChild(span);
});
}
Utils.setupUpload('dropZone-img2vid', 'fileInput-img2vid', 'imagePreview-img2vid', 'removeImgBtn-img2vid', 'uploadPrompt-img2vid');
},
optimizePrompt: async () => {
const val = document.getElementById('img2vidPrompt').value.trim();
if(!val) return Utils.showToast('请输入描述', 'error');
let modelId = document.getElementById('img2vidPolishModelSelect').value ? 'img2vidPolishModelSelect' : 'novelModelSelect';
try {
const res = await API.call(modelId, [{role:"user", content:val}], "改写为详细的运镜与动态描述,用于图生视频生成。");
document.getElementById('img2vidPrompt').value = res.choices[0].message.content;
Utils.showToast('完成');
} catch(e) { Utils.showError("润色失败", e.message); }
},
generate: async () => {
const img = document.getElementById('imagePreview-img2vid').src;
const prompt = document.getElementById('img2vidPrompt').value.trim();
if(!img || img.length<100) return Utils.showToast('请上传图片', 'error');
const runningCount = TaskQueue.getRunningCount();
const queueCount = TaskQueue.getQueueCount();
const orientation = document.getElementById('img2vidRatio').value;
const duration = document.getElementById('img2vidDuration').value;
const modelName = `sora2-${orientation}-${duration}s`;
const fullPrompt = prompt;
const task = TaskModule.create('img2vid', prompt, img, { model: modelName, orientation, duration });
if (!task) {
Utils.showToast('创建任务失败', 'error');
return;
}
const taskId = task.id;
const btn = document.getElementById('generateImg2VideoBtn');
const load = document.getElementById('img2vidLoadingOverlay');
const vid = document.getElementById('img2vidPlayer');
const status = document.getElementById('img2vidStatusText');
const log = document.getElementById('img2vidStreamLog');
const bar = document.getElementById('img2vidProgressBar');
const isCurrentTab = !document.getElementById('tab-img2video').classList.contains('hidden');
if (isCurrentTab) {
load.classList.remove('hidden');
vid.classList.add('hidden');
status.innerText = runningCount >= TaskQueue.maxConcurrent
? `任务已加入队列(运行中: ${runningCount}, 等待中: ${queueCount + 1})...`
: `任务已加入队列,即将开始生成...`;
bar.style.width = '5%';
}
if (runningCount >= TaskQueue.maxConcurrent) {
Utils.showToast(`任务已加入队列(运行中: ${runningCount}, 等待中: ${queueCount + 1})`, 'info');
} else {
Utils.showToast('任务已加入队列,开始生成...', 'success');
}
TaskQueue.add(async () => {
try {
TaskModule.update(taskId, { status: 'generating' });
if (isCurrentTab) {
status.innerText = '上传参考图...';
bar.style.width = '10%';
}
let lastProgress = 10;
const msgs = [{ role: "user", content: [ { type: "text", text: fullPrompt || "animate" }, { type: "image_url", image_url: { url: img } } ] }];
const fullText = await API.chatStream(modelName, msgs, (text) => {
const progressMatch = text.match(/(\d+)%/i) || text.match(/进度[::]\s*(\d+)/i) || text.match(/progress[:\s]+(\d+)/i);
if(progressMatch) {
const progress = parseInt(progressMatch[1]);
if(progress > lastProgress && progress <= 100) {
lastProgress = progress;
if (isCurrentTab) {
bar.style.width = progress + '%';
}
}
} else {
if(text.length > 100 && lastProgress < 30) {
lastProgress = 30;
if (isCurrentTab) {
bar.style.width = '30%';
}
} else if(text.length > 500 && lastProgress < 60) {
lastProgress = 60;
if (isCurrentTab) {
bar.style.width = '60%';
}
}
}
if (isCurrentTab) {
status.innerText = '生成中...';
log.innerText = text.slice(-50);
}
});
if (isCurrentTab) {
bar.style.width = '90%';
status.innerText = '解析视频链接...';
}const errorKeywords = ['❌', '失败', 'error', 'Error', 'ERROR', 'violate', 'guardrails', '违反', '禁止'];
const hasError = errorKeywords.some(keyword => fullText.includes(keyword));
if(hasError) {
let errorMsg = '生成失败';
try {
const jsonMatch = fullText.match(/\{[\s\S]*\}/);
if(jsonMatch) {
const json = JSON.parse(jsonMatch[0]);
if(json.error || json.message || json.msg) {
errorMsg = json.error || json.message || json.msg;
}
}
} catch(e) {
if(e instanceof Error && e.message !== 'Unexpected token') {
errorMsg = e.message;
} else {
errorMsg = fullText.replace(/❌|生成失败[::]\s*/g, '').trim() || '生成失败';
}
}
TaskModule.update(taskId, {
status: 'failed',
errorMessage: errorMsg
});
if (isCurrentTab) {
Utils.showError("生成失败", errorMsg);
load.classList.add('hidden');
}
return;
}
const vUrl = Utils.extractUrl(fullText);
if(!vUrl) {const errorMsg = '未找到视频链接。返回内容: ' + fullText.slice(0, 500);
TaskModule.update(taskId, {
status: 'failed',
errorMessage: errorMsg
});
if (isCurrentTab) {
Utils.showError("生成失败", errorMsg);
load.classList.add('hidden');
}
return;
}TaskModule.update(taskId, {
status: 'completed',
url: vUrl,
rawResponse: fullText.substring(0, 1000)
});
if (isCurrentTab) {
bar.style.width = '100%';
status.innerText = '加载视频...';
vid.src = vUrl;
vid.classList.remove('hidden');
load.classList.add('hidden');
vid.play().catch(e=>{Utils.showToast('视频加载失败,但已保存到任务记录', 'error');
});
}
if (!isCurrentTab) {
Utils.showToast('视频生成完成,可在任务记录中心查看', 'success');
}
} catch(e) {TaskModule.update(taskId, {
status: 'failed',
errorMessage: e.message || '生成失败'
});
if (isCurrentTab) {
Utils.showError("生成失败", e.message);
const hasOtherGenerating = TaskModule.tasks.some(t => t.id !== taskId && t.status === 'generating');
if (!hasOtherGenerating) {
load.classList.add('hidden');
}
}
}
}, taskId);
const updateButtonStatus = () => {
const running = TaskQueue.getRunningCount();
const queued = TaskQueue.getQueueCount();
if (btn) {
if (running >= TaskQueue.maxConcurrent) {
btn.title = `当前有 ${running} 个任务正在生成,${queued} 个任务等待中`;
} else {
btn.title = `当前有 ${running} 个任务正在生成,还可以生成 ${TaskQueue.maxConcurrent - running} 个`;
}
}
};
updateButtonStatus();
const statusInterval = setInterval(() => {
updateButtonStatus();
if (TaskQueue.getRunningCount() === 0 && TaskQueue.getQueueCount() === 0) {
clearInterval(statusInterval);
if (isCurrentTab && load && !vid.classList.contains('hidden')) {
load.classList.add('hidden');
}
}
}, 1000);
}
};
const CharacterModule = {
chars: [],
currentVideoBase64: null,
currentTaskId: null,
currentVideoUrl: null,
init: () => {
try {
const stored = localStorage.getItem('ai_workshop_chars');
CharacterModule.chars = stored ? JSON.parse(stored) : [];
const now = Date.now();
let needSave = false;
CharacterModule.chars.forEach(char => {
if(char.videoData && char.videoData.startsWith('data:video')) {
delete char.videoData;
needSave = true;
}
if(!char.status) {
char.status = 'completed';
needSave = true;
} else if(char.status === 'creating') {
const createTimestamp = char.createTimestamp || 0;
if(createTimestamp && (now - createTimestamp > 300000)) {
char.status = 'failed';
char.errorMessage = '创建超时:页面刷新前未完成创建';
needSave = true;
} else if(!createTimestamp) {
char.createTimestamp = now;
needSave = true;
}
}
});
if(needSave) {
CharacterModule.saveLocal();
}
} catch(e) {
CharacterModule.chars = [];
}
CharacterModule.renderList();
CharacterModule.setupMention();
const dropZone = document.getElementById('charVideoDropZone');
const fileInput = document.getElementById('charVideoInput');
const preview = document.getElementById('extractPreviewVideo');
const prompt = document.getElementById('charVideoPrompt');
if(dropZone && fileInput) {
dropZone.onclick = () => fileInput.click();
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (file) {
prompt.classList.add('hidden');
preview.src = URL.createObjectURL(file);
preview.classList.remove('hidden');
preview.play();
try {
CharacterModule.currentVideoBase64 = await Utils.fileToBase64(file);
document.getElementById('btnStartExtract').disabled = false;
} catch(err) {
Utils.showToast('视频处理失败', 'error');
}
}
};
}
const descInput = document.getElementById('createCharDesc');
if(descInput) {
descInput.addEventListener('input', () => {
document.getElementById('createCharDescCount').innerText = descInput.value.length;
});
}
},
saveLocal: () => {
try {
const charsToSave = CharacterModule.chars.map(char => {
const charCopy = {...char};
if(charCopy.videoData && charCopy.videoData.startsWith('data:video')) {
delete charCopy.videoData;
}
return charCopy;
});
localStorage.setItem('ai_workshop_chars', JSON.stringify(charsToSave));
} catch(e) {
if(e.name === 'QuotaExceededError' || e.message.includes('quota') || e.message.includes('exceeded')) {
try {
if(CharacterModule.chars.length > 5) {
CharacterModule.chars = CharacterModule.chars.slice(-5);
const charsToSave = CharacterModule.chars.map(char => {
const charCopy = {...char};
delete charCopy.videoData;
return charCopy;
});
localStorage.setItem('ai_workshop_chars', JSON.stringify(charsToSave));
Utils.showToast('存儲空間不足,已自動清理舊角色數據', 'warning');
return;
}
} catch(e2) {Utils.showToast('存儲空間不足,請手動清理瀏覽器緩存', 'error');
return;
}
}}
},
openCreateModal: async (taskId, videoUrl) => {
CharacterModule.currentTaskId = taskId;
CharacterModule.currentVideoUrl = videoUrl;
const modal = document.getElementById('createCharModal');
document.getElementById('createCharTaskId').value = taskId;
document.getElementById('createCharName').value = '';
document.getElementById('createCharDesc').value = '';
document.getElementById('createCharDescCount').innerText = '0';
document.getElementById('createCharStartTime').value = 0;
document.getElementById('createCharEndTime').value = 3;
try {
Utils.showToast('正在准备角色卡...');
const p = API.providers[0];
const proxy = p ? p.proxy : '';
CharacterModule.currentVideoBase64 = await Utils.urlToBase64(videoUrl, proxy);
} catch(e) {}
modal.classList.remove('hidden');
},
closeCreateModal: () => {
document.getElementById('createCharModal').classList.add('hidden');
CharacterModule.currentTaskId = null;
CharacterModule.currentVideoUrl = null;
CharacterModule.currentVideoBase64 = null;
},
submitCreateChar: async () => {
const taskId = document.getElementById('createCharTaskId').value;
const name = document.getElementById('createCharName').value.trim();
const desc = document.getElementById('createCharDesc').value.trim();
const startTime = parseFloat(document.getElementById('createCharStartTime').value);
const endTime = parseFloat(document.getElementById('createCharEndTime').value);
if(!taskId || !name || !desc) {
return Utils.showToast('请填写所有必填项', 'error');
}
if(endTime <= startTime) {
return Utils.showToast('结束时间必须大于开始时间', 'error');
}
if(endTime - startTime > 3) {
return Utils.showToast('截取时长不能超过3秒', 'error');
}
const btn = document.getElementById('btnSubmitCreateChar');
btn.disabled = true;
btn.innerHTML = ' 创建中...';
const charId = Utils.generateCharId();
const charData = {
id: charId,
name: name,
trigger: name.toLowerCase().replace(/\s+/g, '') + Utils.generateCharId().slice(-6),
prompt: desc,
avatar: CharacterModule.currentVideoUrl || 'https://placehold.co/300x400/2563eb/ffffff?text=VIDEO',
type: 'video',
taskId: taskId,
videoData: null,
username: '',
status: 'creating',
createTime: new Date().toLocaleString('zh-CN'),
createTimestamp: Date.now(),
startTime: startTime,
endTime: endTime
};
CharacterModule.chars.push(charData);
CharacterModule.saveLocal();
CharacterModule.renderList();
const timeoutId = setTimeout(() => {
if(charData.status === 'creating') {
charData.status = 'failed';
charData.errorMessage = '创建超时:操作超过600秒未完成';
CharacterModule.saveLocal();
CharacterModule.renderList();
Utils.showError('角色创建超时', '操作超过600秒未完成,请检查网络连接或稍后重试');
}
}, 600000);
try {
if(!CharacterModule.currentVideoBase64 && CharacterModule.currentVideoUrl) {
Utils.showToast('正在下载视频...');
const p = API.providers[0];
const proxy = p ? p.proxy : '';
CharacterModule.currentVideoBase64 = await Utils.urlToBase64(CharacterModule.currentVideoUrl, proxy);
}
if(!CharacterModule.currentVideoBase64) {
throw new Error('无法获取视频数据');
}
const videoBase64 = CharacterModule.currentVideoBase64;
Utils.showToast('正在调用API提取角色信息...');
const messages = [{
"role": "user",
"content": [
{
"type": "video_url",
"video_url": { "url": videoBase64 }
}
]
}];
let extractedUsername = '';
let fullText = '';
try {
const statusMessages = [
'角色创建成功', '创建成功', '成功', '已完成', '完成',
'character created', 'created', 'success', 'completed',
'角色提取成功', '提取成功', 'extraction successful',
'角色注册成功', '注册成功', 'registration successful'
];
fullText = await API.chatStream('SORA2_FIXED', messages, (text) => {
const cleanText = text.trim().replace(/\n+/g, ' ').trim();
const hasStatusMessage = statusMessages.some(msg =>
cleanText.includes(msg)
);
const atMatch = cleanText.match(/[@]([^\s,,。\n]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();} else if(cleanText && cleanText.length > 0 && !hasStatusMessage) {
extractedUsername = cleanText;} else if(cleanText && cleanText.length > 0 && hasStatusMessage) {
const atMatch = cleanText.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();} else {
let cleaned = cleanText;
statusMessages.forEach(msg => {
cleaned = cleaned.replace(new RegExp(msg, 'gi'), '').trim();
});
cleaned = cleaned.replace(/^[,,。、\s]+|[,,。、\s]+$/g, '').trim();
if(cleaned && cleaned.length > 0 && cleaned.length < 100) {
extractedUsername = cleaned;}
}
}
});
} catch (apiError) {if (apiError.message && (apiError.message.includes('HTTP2') || apiError.message.includes('PROTOCOL_ERROR'))) {
throw new Error('网络协议错误,请检查API服务器状态或稍后重试');
}
throw apiError;
}
if(!extractedUsername && fullText) {
let tempUsername = fullText.trim().replace(/\n+/g, ' ').trim();const roleNameMatch = tempUsername.match(/角色名\s*[@::]\s*([^\s,,。]+)/i) ||
tempUsername.match(/角色名\s+([@][^\s,,。]+)/i) ||
tempUsername.match(/角色名\s*[@::]\s*[@]?([^\s,,。]+)/i);
if(roleNameMatch && roleNameMatch[1]) {
let matched = roleNameMatch[1];
if(!matched.startsWith('@') && tempUsername.includes('@')) {
const atMatch = tempUsername.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
matched = '@' + atMatch[1];
} else {
matched = '@' + matched;
}
} else if(!matched.startsWith('@')) {
if(tempUsername.includes('@')) {
matched = '@' + matched;
}
}
extractedUsername = matched.trim();}
if(!extractedUsername) {
const atMatch = tempUsername.match(/[@]([^\s,,。\n]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();}
}
if(!extractedUsername) {
const statusMessages = [
'角色创建成功', '创建成功', '成功', '已完成', '完成',
'character created', 'created', 'success', 'completed',
'角色提取成功', '提取成功', 'extraction successful',
'角色注册成功', '注册成功', 'registration successful'
];
let cleaned = tempUsername;
statusMessages.forEach(msg => {
cleaned = cleaned.replace(new RegExp(msg, 'gi'), '').trim();
});
cleaned = cleaned.replace(/^[,,。、\s]+|[,,。、\s]+$/g, '').trim();
if(cleaned && cleaned.length > 0 && cleaned.length < 100) {
extractedUsername = cleaned;}
}
}
if(!extractedUsername && fullText) {
const atMatch = fullText.match(/[@]([^\s,,。\n]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();}
}
if(extractedUsername) {
extractedUsername = extractedUsername
.replace(/^角色[名名]?[::]\s*/i, '')
.replace(/^character[:\s]+/i, '')
.replace(/^username[:\s]+/i, '')
.replace(/^用户[名名]?[::]\s*/i, '')
.replace(/^名称[::]\s*/i, '')
.trim();
if(!extractedUsername.startsWith('@') && fullText.includes('@')) {
const atMatch = fullText.match(/[@]([^\s,,。\n]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();
}
}
extractedUsername = extractedUsername.split('\n')[0].split(',')[0].split(',')[0].split('。')[0].trim();
}if(!extractedUsername || extractedUsername.length === 0) {const finalAtMatch = fullText.match(/[@]([^\s,,。\n]+)/);
if(finalAtMatch && finalAtMatch[1]) {
extractedUsername = '@' + finalAtMatch[1].trim();}
}
if(extractedUsername && extractedUsername.length > 0 && extractedUsername.length < 100) {
const statusMessages = [
'角色创建成功', '创建成功', '成功', '已完成', '完成',
'character created', 'created', 'success', 'completed',
'角色提取成功', '提取成功', 'extraction successful',
'角色注册成功', '注册成功', 'registration successful'
];
const isStatusMessage = statusMessages.some(msg =>
extractedUsername.toLowerCase().includes(msg.toLowerCase())
);
if(!isStatusMessage) {
charData.username = extractedUsername;} else {
throw new Error(`API返回的内容似乎是状态消息而非角色名: "${extractedUsername}"。完整响应: ${fullText ? fullText.substring(0, 300) : '空响应'}`);
}
} else {
throw new Error(`API未返回有效的角色名。提取的内容: "${extractedUsername}"。完整响应: ${fullText ? fullText.substring(0, 500) : '空响应'}`);
}
clearTimeout(timeoutId);
charData.status = 'completed';
delete charData.createTimestamp;
CharacterModule.saveLocal();
CharacterModule.renderList();
CharacterModule.closeCreateModal();
Utils.showToast('角色创建成功!');
} catch(e) {
clearTimeout(timeoutId);
charData.status = 'failed';
charData.errorMessage = e.message || '未知错误';
delete charData.createTimestamp;
CharacterModule.saveLocal();
CharacterModule.renderList();
Utils.showError('角色创建失败', e.message);
} finally {
btn.disabled = false;
btn.innerHTML = ' 创建角色';
}
},
openExtractModal: async (videoUrl = null, taskId = null) => {
const modal = document.getElementById('extractCharModal');
const preview = document.getElementById('extractPreviewVideo');
const prompt = document.getElementById('charVideoPrompt');
const btn = document.getElementById('btnSaveChar');
const timeConfig = document.getElementById('videoTimelineConfig');
CharacterModule.currentTaskId = taskId;
document.getElementById('extractTaskId').innerText = taskId || "N/A (手动上传)";
document.getElementById('charTaskInfo').classList.remove('hidden');
modal.classList.remove('hidden');
document.getElementById('charExtractProgress').classList.add('hidden');
document.getElementById('extractedCharName').value = '';
document.getElementById('extractedCharTrigger').value = '';
document.getElementById('extractCharDesc').value = '';
document.getElementById('extractStartTime').value = 0;
document.getElementById('extractEndTime').value = 3;
if (videoUrl) {
prompt.classList.add('hidden');
timeConfig.classList.remove('hidden');
preview.src = videoUrl;
preview.classList.remove('hidden');
preview.play();
try {
Utils.showToast('正在下载视频...');
const p = API.providers[0];
const proxy = p ? p.proxy : '';
CharacterModule.currentVideoBase64 = await Utils.urlToBase64(videoUrl, proxy);
Utils.showToast('视频就绪');
} catch (e) {
Utils.showError('视频下载失败', '无法自动转换此视频为 Base64,请手动上传本地文件。\n原因: ' + e.message);
}
} else {
prompt.classList.remove('hidden');
timeConfig.classList.add('hidden');
preview.classList.add('hidden');
preview.src = '';
CharacterModule.currentVideoBase64 = null;
}
},
validateTime: () => {
const start = parseFloat(document.getElementById('extractStartTime').value);
let end = parseFloat(document.getElementById('extractEndTime').value);
if (end - start > 3) {
end = start + 3;
document.getElementById('extractEndTime').value = end;
Utils.showToast('截取时长限制为3秒内');
}
if (end <= start) {
end = start + 1;
document.getElementById('extractEndTime').value = end;
}
},
confirmSave: async () => {
const name = document.getElementById('extractedCharName').value;
const trigger = document.getElementById('extractedCharTrigger').value;
const desc = document.getElementById('extractCharDesc').value;
if(!name || !trigger) return Utils.showToast('请填写角色名和Trigger ID', 'error');
if(!CharacterModule.currentVideoBase64) return Utils.showToast('请等待视频加载完成', 'error');
const btn = document.getElementById('btnSaveChar');
const progressDiv = document.getElementById('charExtractProgress');
const progressBar = progressDiv.querySelector('div div');
const statusSpan = document.getElementById('charExtractStatus');
btn.disabled = true;
progressDiv.classList.remove('hidden');
statusSpan.innerText = '正在调用 Sora API 注册角色...';
progressBar.style.width = '30%';
try {
const messages = [{
"role": "user",
"content": [
{
"type": "video_url",
"video_url": { "url": CharacterModule.currentVideoBase64 }
}
]
}];
progressBar.style.width = '60%';
await API.chatStream('SORA2_FIXED', messages, (text) => {});
progressBar.style.width = '100%';
const now = new Date();
const createTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
const charData = {
id: Date.now().toString(),
name: name,
trigger: trigger,
prompt: desc || `Sora2 Character: ${name}`,
avatar: CharacterModule.currentVideoBase64.startsWith('data:') ? 'https://placehold.co/300x400/2563eb/ffffff?text=VIDEO' : document.getElementById('extractPreviewVideo').src,
type: 'video',
taskId: CharacterModule.currentTaskId,
videoData: CharacterModule.currentVideoBase64,
createTime: createTime
};
CharacterModule.chars.push(charData);
CharacterModule.saveLocal();
CharacterModule.renderList();
document.getElementById('extractCharModal').classList.add('hidden');
Utils.showToast("角色注册成功");
} catch(e) {
Utils.showError('角色创建失败', e.message);
} finally {
btn.disabled = false;
progressDiv.classList.add('hidden');
}
},
del: (id) => {
if(!confirm('确定删除此角色?')) return;
CharacterModule.chars = CharacterModule.chars.filter(c=>c.id!==id);
CharacterModule.saveLocal();
CharacterModule.renderList();
},
editChar: (id) => {
const charData = CharacterModule.chars.find(c => c.id === id);
if(!charData) return;
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if(e.target === modal) modal.remove();
});
},
saveEdit: (id) => {
const charData = CharacterModule.chars.find(c => c.id === id);
if(!charData) return;
const name = document.getElementById('editCharName').value.trim();
const username = document.getElementById('editCharUsername').value.trim();
const desc = document.getElementById('editCharDesc').value.trim();
if(!name) {
Utils.showToast('请填写角色名', 'error');
return;
}
if(username && !/^[a-z0-9]{8}$/.test(username)) {
Utils.showToast('用户名必须是8位小写字母和数字', 'error');
return;
}
charData.name = name;
if(username) {
charData.username = username;
}
if(desc) {
charData.prompt = desc;
}
CharacterModule.saveLocal();
CharacterModule.renderList();
const modal = document.querySelector('.fixed.inset-0.bg-black\\/50');
if(modal) modal.remove();
Utils.showToast('角色信息已更新');
},
retryCreate: async (id) => {
const charData = CharacterModule.chars.find(c => c.id === id);
if(!charData || charData.status !== 'failed') return;
if(!confirm('确定要重试创建此角色吗?')) return;
charData.status = 'creating';
charData.createTimestamp = Date.now();
delete charData.errorMessage;
CharacterModule.saveLocal();
CharacterModule.renderList();
const timeoutId = setTimeout(() => {
if(charData.status === 'creating') {
charData.status = 'failed';
charData.errorMessage = '创建超时:操作超过600秒未完成';
delete charData.createTimestamp;
CharacterModule.saveLocal();
CharacterModule.renderList();
Utils.showError('角色创建超时', '操作超过600秒未完成,请检查网络连接或稍后重试');
}
}, 600000);
try {
let videoBase64 = null;
if(charData.avatar && charData.avatar.startsWith('http')) {
Utils.showToast('正在从URL下载视频...');
const p = API.providers[0];
const proxy = p ? p.proxy : '';
videoBase64 = await Utils.urlToBase64(charData.avatar, proxy);
}
if(!videoBase64) {
throw new Error('无法获取视频数据,请确保视频URL有效');
}
Utils.showToast('正在调用API提取角色信息...');
const messages = [{
"role": "user",
"content": [
{
"type": "video_url",
"video_url": { "url": videoBase64 }
}
]
}];
let extractedUsername = '';
let fullText = '';
try {
const statusMessages = [
'角色创建成功', '创建成功', '成功', '已完成', '完成',
'character created', 'created', 'success', 'completed',
'角色提取成功', '提取成功', 'extraction successful',
'角色注册成功', '注册成功', 'registration successful'
];
fullText = await API.chatStream('SORA2_FIXED', messages, (text) => {
const cleanText = text.trim().replace(/\n+/g, ' ').trim();
const roleNameMatch = cleanText.match(/角色名[@::]\s*([^\s,,。]+)/i) ||
cleanText.match(/角色名\s*([@][^\s,,。]+)/i);
if(roleNameMatch && roleNameMatch[1]) {
let matched = roleNameMatch[1];
if(!matched.includes('@') && cleanText.includes('@')) {
const atMatch = cleanText.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
matched = '@' + atMatch[1];
}
} else if(matched.includes('@')) {
matched = matched;
} else if(cleanText.includes('@')) {
const atMatch = cleanText.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
matched = '@' + atMatch[1];
}
}
extractedUsername = matched.trim();} else {
const hasStatusMessage = statusMessages.some(msg =>
cleanText.includes(msg)
);
if(cleanText && cleanText.length > 0 && !hasStatusMessage) {
extractedUsername = cleanText;} else if(cleanText && cleanText.length > 0 && hasStatusMessage) {
const atMatch = cleanText.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();} else {
let cleaned = cleanText;
statusMessages.forEach(msg => {
cleaned = cleaned.replace(new RegExp(msg, 'gi'), '').trim();
});
cleaned = cleaned.replace(/^[,,。、\s]+|[,,。、\s]+$/g, '').trim();
if(cleaned && cleaned.length > 0 && cleaned.length < 100) {
extractedUsername = cleaned;}
}
}
}
});
} catch (apiError) {if (apiError.message && (apiError.message.includes('HTTP2') || apiError.message.includes('PROTOCOL_ERROR'))) {
throw new Error('网络协议错误,请检查API服务器状态或稍后重试');
}
throw apiError;
}
if(!extractedUsername && fullText) {
let tempUsername = fullText.trim().replace(/\n+/g, ' ').trim();const roleNameMatch = tempUsername.match(/角色名[@::]\s*([^\s,,。]+)/i) ||
tempUsername.match(/角色名\s*([@][^\s,,。]+)/i) ||
tempUsername.match(/[@]([^\s,,。]+)/);
if(roleNameMatch && roleNameMatch[1]) {
let matched = roleNameMatch[1];
if(!matched.includes('@') && tempUsername.includes('@')) {
const atMatch = tempUsername.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
matched = '@' + atMatch[1];
}
} else if(matched.includes('@')) {
matched = matched;
} else if(tempUsername.includes('@')) {
matched = '@' + matched;
}
extractedUsername = matched.trim();} else {
const atMatch = tempUsername.match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();}
}
}
if(extractedUsername) {
extractedUsername = extractedUsername
.replace(/^角色[名名]?[::]\s*/i, '')
.replace(/^character[:\s]+/i, '')
.replace(/^username[:\s]+/i, '')
.replace(/^用户[名名]?[::]\s*/i, '')
.replace(/^名称[::]\s*/i, '')
.trim();
const statusMessages = [
'角色创建成功', '创建成功', '成功', '已完成', '完成',
'character created', 'created', 'success', 'completed',
'角色提取成功', '提取成功', 'extraction successful',
'角色注册成功', '注册成功', 'registration successful'
];
const hasStatusMessage = statusMessages.some(msg =>
extractedUsername.includes(msg)
);
if(hasStatusMessage) {
const roleNameMatch = extractedUsername.match(/角色名[@::]\s*([^\s,,。]+)/i) ||
extractedUsername.match(/角色名\s*([@][^\s,,。]+)/i) ||
fullText.match(/角色名[@::]\s*([^\s,,。]+)/i) ||
fullText.match(/角色名\s*([@][^\s,,。]+)/i);
if(roleNameMatch && roleNameMatch[1]) {
let matched = roleNameMatch[1];
if(!matched.includes('@') && (extractedUsername.includes('@') || fullText.includes('@'))) {
const atMatch = (extractedUsername.includes('@') ? extractedUsername : fullText).match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
matched = '@' + atMatch[1];
}
} else if(matched.includes('@')) {
matched = matched;
} else if(extractedUsername.includes('@') || fullText.includes('@')) {
const atMatch = (extractedUsername.includes('@') ? extractedUsername : fullText).match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
matched = '@' + atMatch[1];
}
}
extractedUsername = matched.trim();} else {
const atMatch = (extractedUsername.includes('@') ? extractedUsername : fullText).match(/[@]([^\s,,。]+)/);
if(atMatch && atMatch[1]) {
extractedUsername = '@' + atMatch[1].trim();} else {
let cleaned = extractedUsername;
statusMessages.forEach(msg => {
cleaned = cleaned.replace(new RegExp(msg, 'gi'), '').trim();
});
cleaned = cleaned.replace(/^[,,。、\s]+|[,,。、\s]+$/g, '').trim();
if(cleaned && cleaned.length > 0 && cleaned.length < 100) {
extractedUsername = cleaned;}
}
}
}
extractedUsername = extractedUsername.split('\n')[0].split(',')[0].split(',')[0].split('。')[0].trim();
}if(extractedUsername && extractedUsername.length > 0 && extractedUsername.length < 100) {
const statusMessages = [
'角色创建成功', '创建成功', '成功', '已完成', '完成',
'character created', 'created', 'success', 'completed',
'角色提取成功', '提取成功', 'extraction successful',
'角色注册成功', '注册成功', 'registration successful'
];
const isStatusMessage = statusMessages.some(msg =>
extractedUsername.toLowerCase().includes(msg.toLowerCase())
);
if(!isStatusMessage) {
charData.username = extractedUsername;} else {
throw new Error(`API返回的内容似乎是状态消息而非角色名: "${extractedUsername}"。完整响应: ${fullText ? fullText.substring(0, 300) : '空响应'}`);
}
} else {
throw new Error(`API未返回有效的角色名。提取的内容: "${extractedUsername}"。完整响应: ${fullText ? fullText.substring(0, 500) : '空响应'}`);
}
clearTimeout(timeoutId);
charData.status = 'completed';
delete charData.createTimestamp;
CharacterModule.saveLocal();
CharacterModule.renderList();
Utils.showToast('角色创建成功!');
} catch(e) {
clearTimeout(timeoutId);
charData.status = 'failed';
charData.errorMessage = e.message || '未知错误';
delete charData.createTimestamp;
CharacterModule.saveLocal();
CharacterModule.renderList();
Utils.showError('角色创建失败', e.message);
}
},
renderList: () => {
const c = document.getElementById('charList');
if(!c) return;
c.innerHTML = CharacterModule.chars.map(char => {
const createTime = char.createTime || (() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
})();
return `
${char.status === 'failed' && char.errorMessage ? `
`;
}).join('');
},
refreshList: () => {
CharacterModule.renderList();
Utils.showToast('列表已刷新');
},
setupMention: () => {
['videoPrompt', 'img2vidPrompt'].forEach(id => {
const ta = document.getElementById(id), dd = document.getElementById('mentionDropdown');
if(!ta) return;
ta.addEventListener('input', () => {
const val = ta.value.slice(0, ta.selectionStart);
const match = val.match(/@(\w*)$/);
if(match) {
const q = match[1].toLowerCase();
const hits = CharacterModule.chars.filter(c => c.trigger.toLowerCase().startsWith(q));
if(hits.length) {
dd.style.display = 'block'; dd.innerHTML = hits.map(c => `
${char.status === 'failed' && char.errorMessage ? `
`;
}).join('');
});
}
},
insert: (inputId, ddId, name, prompt) => {
const ta = document.getElementById(inputId);
const pos = ta.selectionStart;
const val = ta.value;
const before = val.slice(0, pos).replace(/@(\w*)$/, '');
ta.value = `${before} (Character ${name}: ${prompt}) ${val.slice(pos)}`;
document.getElementById('mentionDropdown').style.display = 'none';
ta.focus();
}
};
const NovelModule = { convert: async () => { const val = document.getElementById('novelInput').value.trim(); if(!val) return Utils.showToast('请输入内容','error'); const btn=document.getElementById('novelConvertBtn'), out=document.getElementById('novelOutput'); btn.disabled=true; btn.innerText="生成中..."; try { const res = await API.call('novelModelSelect', [{role:"user", content:val}], "Convert novel to AI video prompts."); out.value = res.choices[0].message.content; } catch(e) { Utils.showError("转换失败", e.message); } finally { btn.disabled=false; btn.innerText="转换"; } } };
const VisionModule = {
init: () => {
Utils.setupUpload('dropZone-vision', 'fileInput-vision', 'imagePreview-vision', 'removeImgBtn-vision', 'uploadPrompt-vision');
},
analyze: async () => {
const img = document.getElementById('imagePreview-vision').src;
const instruction = document.getElementById('visionInstruction').value.trim();
const el = document.getElementById('visionModelSelect');
const resultEl = document.getElementById('visionResult');
const loadingEl = document.getElementById('visionLoading');
const btn = document.getElementById('visionConvertBtn');
if(!img || img.length < 100 || !el.value) {
return Utils.showToast('请检查图片及配置', 'error');
}
btn.disabled = true;
loadingEl.classList.remove('hidden');
resultEl.value = '';
try {
const prompt = instruction || '请详细描述这张图片的内容';
const msgs = [{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: img } }
]
}];
const fullText = await API.chatStream('visionModelSelect', msgs, (text) => {
resultEl.value = text;
});
if(fullText) {
resultEl.value = fullText;
}
Utils.showToast('分析完成', 'success');
} catch(e) {
Utils.showError("分析失败", e.message);} finally {
btn.disabled = false;
loadingEl.classList.add('hidden');
}
}
};
const VideoAnalysisModule = { init: () => { }, analyze: async () => { } };
const UI = {
switchTab: (id) => {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
document.getElementById(`tab-${id}`).classList.remove('hidden');
document.getElementById(`nav-${id}`).classList.add('active');
if(id === 'tasks') TaskModule.render();
},
openSettings: () => document.getElementById('settingsModal').classList.remove('hidden'),
closeSettings: () => document.getElementById('settingsModal').classList.add('hidden')
};
setInterval(() => {
const hasGeneratingTasks = TaskModule.tasks.some(t => t.status === 'generating');
if (hasGeneratingTasks) {
const tasksTab = document.getElementById('tab-tasks');
if (tasksTab && !tasksTab.classList.contains('hidden')) {
TaskModule.render();
}
}
}, 5000);
document.addEventListener('DOMContentLoaded', async () => {
try {
const savedApiKey = localStorage.getItem('apiKey');
if(savedApiKey) {
const apiKeyInput = document.getElementById('globalApiKeyInput');
if(apiKeyInput) {
apiKeyInput.value = savedApiKey;
}
}
await API.init();
TaskModule.init();
CharacterModule.init();
VisionModule.init();
VideoAnalysisModule.init();
VideoModule.init();
Img2VideoModule.init();
UI.switchTab('video');
TaskModule.render();
setTimeout(() => {
try { API.updateSelects(); } catch(e) {}
}, 500);
} catch(err) {alert("初始化失败,请点击右下角的红色重置按钮修复。");
}
});
编辑角色信息
只能包含小写字母和数字,共8位
${char.name}
${char.status === 'creating' ? '创建中' : char.status === 'failed' ? '创建失败' : '已完成'}${char.prompt || '暂无描述'}
${createTime}
${char.id}
${char.status === 'creating' ? '创建中' : (char.username || '未设置')}
错误信息:${char.errorMessage}
` : ''}
@${c.trigger} ${c.name}
`).join('');
return;
}
}
dd.style.display = 'none';
});
});
const searchInput = document.getElementById('charSearchInput');
if(searchInput) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = query ? CharacterModule.chars.filter(c =>
c.name.toLowerCase().includes(query) ||
(c.prompt && c.prompt.toLowerCase().includes(query))
) : CharacterModule.chars;
const c = document.getElementById('charList');
if(!c) return;
c.innerHTML = filtered.map(char => {
const createTime = char.createTime || (() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
})();
return `
${char.name}
${char.status === 'creating' ? '创建中' : char.status === 'failed' ? '创建失败' : '已完成'}${char.prompt || '暂无描述'}
${createTime}
${char.id}
${char.status === 'creating' ? '创建中' : (char.username || '未设置')}
错误信息:${char.errorMessage}
` : ''}