1114 lines
32 KiB
Plaintext
1114 lines
32 KiB
Plaintext
|
|
// ==================== HoloLake 动态漫制作系统 v5.0 ====================
|
|||
|
|
// 环节5:动画时间轴(帧序列 + 播放控制 + 帧率调节)
|
|||
|
|
|
|||
|
|
const STORAGE_KEY = 'hololake-comic-studio-data';
|
|||
|
|
const canvas = document.getElementById('main-canvas');
|
|||
|
|
const ctx = canvas.getContext('2d');
|
|||
|
|
|
|||
|
|
// ==================== 全局状态 ====================
|
|||
|
|
let appState = {
|
|||
|
|
currentSceneId: 1,
|
|||
|
|
nextSceneId: 2,
|
|||
|
|
nextAssetId: 1,
|
|||
|
|
nextFrameId: 1,
|
|||
|
|
scenes: [],
|
|||
|
|
playback: {
|
|||
|
|
isPlaying: false,
|
|||
|
|
fps: 4,
|
|||
|
|
intervalId: null,
|
|||
|
|
currentFrameIndex: 0
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 初始化 ====================
|
|||
|
|
function init() {
|
|||
|
|
loadFromStorage();
|
|||
|
|
if (appState.scenes.length === 0) {
|
|||
|
|
createDefaultScene();
|
|||
|
|
}
|
|||
|
|
setupEventListeners();
|
|||
|
|
renderSceneTabs();
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
updatePlaybackControls();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 创建默认场景
|
|||
|
|
function createDefaultScene() {
|
|||
|
|
const defaultScene = {
|
|||
|
|
id: 1,
|
|||
|
|
name: '场景 1',
|
|||
|
|
currentFrameIndex: 0,
|
|||
|
|
frames: [{
|
|||
|
|
id: 1,
|
|||
|
|
assets: []
|
|||
|
|
}],
|
|||
|
|
nextFrameId: 2
|
|||
|
|
};
|
|||
|
|
appState.scenes.push(defaultScene);
|
|||
|
|
appState.currentSceneId = 1;
|
|||
|
|
saveToStorage();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 存储管理 ====================
|
|||
|
|
function saveToStorage() {
|
|||
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(appState));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function loadFromStorage() {
|
|||
|
|
const data = localStorage.getItem(STORAGE_KEY);
|
|||
|
|
if (data) {
|
|||
|
|
const parsed = JSON.parse(data);
|
|||
|
|
// 向后兼容:旧数据没有 frames 字段
|
|||
|
|
if (parsed.scenes && parsed.scenes.length > 0 && !parsed.scenes[0].frames) {
|
|||
|
|
parsed.scenes = parsed.scenes.map(scene => ({
|
|||
|
|
...scene,
|
|||
|
|
currentFrameIndex: 0,
|
|||
|
|
frames: [{
|
|||
|
|
id: 1,
|
|||
|
|
assets: scene.assets || []
|
|||
|
|
}],
|
|||
|
|
nextFrameId: 2
|
|||
|
|
}));
|
|||
|
|
delete parsed.scenes[0].assets; // 删除旧的 assets 字段
|
|||
|
|
}
|
|||
|
|
// 确保 playback 对象存在
|
|||
|
|
if (!parsed.playback) {
|
|||
|
|
parsed.playback = { isPlaying: false, fps: 4, intervalId: null, currentFrameIndex: 0 };
|
|||
|
|
}
|
|||
|
|
appState = parsed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 场景管理 ====================
|
|||
|
|
function addScene() {
|
|||
|
|
const newScene = {
|
|||
|
|
id: appState.nextSceneId++,
|
|||
|
|
name: `场景 ${appState.scenes.length + 1}`,
|
|||
|
|
currentFrameIndex: 0,
|
|||
|
|
frames: [{
|
|||
|
|
id: 1,
|
|||
|
|
assets: []
|
|||
|
|
}],
|
|||
|
|
nextFrameId: 2
|
|||
|
|
};
|
|||
|
|
appState.scenes.push(newScene);
|
|||
|
|
appState.currentSceneId = newScene.id;
|
|||
|
|
saveToStorage();
|
|||
|
|
renderSceneTabs();
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function switchScene(sceneId) {
|
|||
|
|
if (appState.playback.isPlaying) {
|
|||
|
|
stopAnimation();
|
|||
|
|
}
|
|||
|
|
appState.currentSceneId = sceneId;
|
|||
|
|
saveToStorage();
|
|||
|
|
renderSceneTabs();
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deleteScene(sceneId) {
|
|||
|
|
if (appState.scenes.length <= 1) {
|
|||
|
|
alert('至少保留一个场景!');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
appState.scenes = appState.scenes.filter(s => s.id !== sceneId);
|
|||
|
|
if (appState.currentSceneId === sceneId) {
|
|||
|
|
appState.currentSceneId = appState.scenes[0].id;
|
|||
|
|
}
|
|||
|
|
saveToStorage();
|
|||
|
|
renderSceneTabs();
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCurrentScene() {
|
|||
|
|
return appState.scenes.find(s => s.id === appState.currentSceneId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCurrentFrame() {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene) return null;
|
|||
|
|
return scene.frames[scene.currentFrameIndex];
|
|||
|
|
}// ==================== 帧管理 ====================
|
|||
|
|
function addFrame() {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene) return;
|
|||
|
|
|
|||
|
|
// 深拷贝当前帧的素材状态
|
|||
|
|
const currentFrame = scene.frames[scene.currentFrameIndex];
|
|||
|
|
const newFrame = {
|
|||
|
|
id: scene.nextFrameId++,
|
|||
|
|
assets: JSON.parse(JSON.stringify(currentFrame.assets))
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 在当前帧后插入新帧
|
|||
|
|
scene.frames.splice(scene.currentFrameIndex + 1, 0, newFrame);
|
|||
|
|
scene.currentFrameIndex++;
|
|||
|
|
|
|||
|
|
saveToStorage();
|
|||
|
|
renderTimeline();
|
|||
|
|
renderCanvas();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deleteFrame() {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene) return;
|
|||
|
|
|
|||
|
|
if (scene.frames.length <= 1) {
|
|||
|
|
alert('至少保留一帧!');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
scene.frames.splice(scene.currentFrameIndex, 1);
|
|||
|
|
if (scene.currentFrameIndex >= scene.frames.length) {
|
|||
|
|
scene.currentFrameIndex = scene.frames.length - 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
saveToStorage();
|
|||
|
|
renderTimeline();
|
|||
|
|
renderCanvas();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function switchFrame(frameIndex) {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene || frameIndex < 0 || frameIndex >= scene.frames.length) return;
|
|||
|
|
|
|||
|
|
// 保存当前画布状态到当前帧
|
|||
|
|
captureFrame();
|
|||
|
|
|
|||
|
|
scene.currentFrameIndex = frameIndex;
|
|||
|
|
saveToStorage();
|
|||
|
|
renderTimeline();
|
|||
|
|
renderCanvas();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function captureFrame() {
|
|||
|
|
// 画布状态实时保存在 assets 中,无需额外操作
|
|||
|
|
saveToStorage();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 播放引擎 ====================
|
|||
|
|
function playAnimation() {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene || scene.frames.length <= 1) return;
|
|||
|
|
|
|||
|
|
appState.playback.isPlaying = true;
|
|||
|
|
updatePlaybackControls();
|
|||
|
|
|
|||
|
|
const intervalMs = 1000 / appState.playback.fps;
|
|||
|
|
|
|||
|
|
appState.playback.intervalId = setInterval(() => {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene) return;
|
|||
|
|
|
|||
|
|
scene.currentFrameIndex++;
|
|||
|
|
if (scene.currentFrameIndex >= scene.frames.length) {
|
|||
|
|
scene.currentFrameIndex = 0; // 循环播放
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
saveToStorage();
|
|||
|
|
}, intervalMs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pauseAnimation() {
|
|||
|
|
appState.playback.isPlaying = false;
|
|||
|
|
if (appState.playback.intervalId) {
|
|||
|
|
clearInterval(appState.playback.intervalId);
|
|||
|
|
appState.playback.intervalId = null;
|
|||
|
|
}
|
|||
|
|
updatePlaybackControls();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopAnimation() {
|
|||
|
|
pauseAnimation();
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (scene) {
|
|||
|
|
scene.currentFrameIndex = 0;
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
saveToStorage();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function togglePlayback() {
|
|||
|
|
if (appState.playback.isPlaying) {
|
|||
|
|
pauseAnimation();
|
|||
|
|
} else {
|
|||
|
|
playAnimation();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setFPS(fps) {
|
|||
|
|
appState.playback.fps = parseInt(fps);
|
|||
|
|
document.getElementById('fps-display').textContent = fps + ' FPS';
|
|||
|
|
saveToStorage();
|
|||
|
|
|
|||
|
|
// 如果正在播放,重启定时器以应用新帧率
|
|||
|
|
if (appState.playback.isPlaying) {
|
|||
|
|
pauseAnimation();
|
|||
|
|
playAnimation();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updatePlaybackControls() {
|
|||
|
|
const btnPlay = document.getElementById('btn-play');
|
|||
|
|
if (appState.playback.isPlaying) {
|
|||
|
|
btnPlay.textContent = '⏸️';
|
|||
|
|
btnPlay.title = '暂停';
|
|||
|
|
} else {
|
|||
|
|
btnPlay.textContent = '▶️';
|
|||
|
|
btnPlay.title = '播放';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateFrameCounter() {
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene) return;
|
|||
|
|
const counter = document.getElementById('frame-counter');
|
|||
|
|
counter.textContent = `帧 ${scene.currentFrameIndex + 1}/${scene.frames.length}`;
|
|||
|
|
}// ==================== 渲染函数 ====================
|
|||
|
|
function renderSceneTabs() {
|
|||
|
|
const tabsList = document.getElementById('tabs-list');
|
|||
|
|
tabsList.innerHTML = '';
|
|||
|
|
|
|||
|
|
appState.scenes.forEach(scene => {
|
|||
|
|
const tab = document.createElement('div');
|
|||
|
|
tab.className = 'scene-tab' + (scene.id === appState.currentSceneId ? ' active' : '');
|
|||
|
|
tab.innerHTML = `
|
|||
|
|
${scene.name}
|
|||
|
|
<span class="close-btn" onclick="event.stopPropagation(); deleteScene(${scene.id})">×</span>
|
|||
|
|
`;
|
|||
|
|
tab.onclick = () => switchScene(scene.id);
|
|||
|
|
tabsList.appendChild(tab);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTimeline() {
|
|||
|
|
const timelineFrames = document.getElementById('timeline-frames');
|
|||
|
|
timelineFrames.innerHTML = '';
|
|||
|
|
|
|||
|
|
const scene = getCurrentScene();
|
|||
|
|
if (!scene) return;
|
|||
|
|
|
|||
|
|
scene.frames.forEach((frame, index) => {
|
|||
|
|
const thumb = document.createElement('div');
|
|||
|
|
thumb.className = 'frame-thumb' + (index === scene.currentFrameIndex ? ' active' : '');
|
|||
|
|
thumb.setAttribute('data-frame', index + 1);
|
|||
|
|
thumb.textContent = `帧 ${index + 1}`;
|
|||
|
|
thumb.onclick = () => switchFrame(index);
|
|||
|
|
timelineFrames.appendChild(thumb);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCanvas() {
|
|||
|
|
// 清空画布
|
|||
|
|
ctx.fillStyle = '#ffffff';
|
|||
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|||
|
|
|
|||
|
|
const frame = getCurrentFrame();
|
|||
|
|
if (!frame) return;
|
|||
|
|
|
|||
|
|
// 绘制所有素材
|
|||
|
|
frame.assets.forEach(asset => {
|
|||
|
|
ctx.font = '48px Arial';
|
|||
|
|
ctx.textAlign = 'center';
|
|||
|
|
ctx.textBaseline = 'middle';
|
|||
|
|
|
|||
|
|
// 绘制阴影
|
|||
|
|
ctx.shadowColor = 'rgba(0,0,0,0.3)';
|
|||
|
|
ctx.shadowBlur = 4;
|
|||
|
|
ctx.shadowOffsetX = 2;
|
|||
|
|
ctx.shadowOffsetY = 2;
|
|||
|
|
|
|||
|
|
ctx.fillText(asset.emoji, asset.x, asset.y);
|
|||
|
|
|
|||
|
|
// 重置阴影
|
|||
|
|
ctx.shadowColor = 'transparent';
|
|||
|
|
ctx.shadowBlur = 0;
|
|||
|
|
ctx.shadowOffsetX = 0;
|
|||
|
|
ctx.shadowOffsetY = 0;
|
|||
|
|
|
|||
|
|
// 如果是选中状态,绘制边框
|
|||
|
|
if (asset.selected) {
|
|||
|
|
ctx.strokeStyle = '#e94560';
|
|||
|
|
ctx.lineWidth = 2;
|
|||
|
|
ctx.setLineDash([5, 5]);
|
|||
|
|
ctx.strokeRect(asset.x - 30, asset.y - 30, 60, 60);
|
|||
|
|
ctx.setLineDash([]);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 素材拖放 ====================
|
|||
|
|
function setupEventListeners() {
|
|||
|
|
// 素材库拖放
|
|||
|
|
const assetItems = document.querySelectorAll('.asset-item');
|
|||
|
|
assetItems.forEach(item => {
|
|||
|
|
item.addEventListener('dragstart', (e) => {
|
|||
|
|
e.dataTransfer.setData('type', item.dataset.type);
|
|||
|
|
e.dataTransfer.setData('emoji', item.dataset.emoji);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 画布接收拖放
|
|||
|
|
canvas.addEventListener('dragover', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
canvas.addEventListener('drop', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const type = e.dataTransfer.getData('type');
|
|||
|
|
const emoji = e.dataTransfer.getData('emoji');
|
|||
|
|
|
|||
|
|
if (emoji) {
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
const x = e.clientX - rect.left;
|
|||
|
|
const y = e.clientY - rect.top;
|
|||
|
|
|
|||
|
|
addAssetToCanvas(emoji, x, y);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 画布点击选择/移动
|
|||
|
|
let isDragging = false;
|
|||
|
|
let dragStartX, dragStartY;
|
|||
|
|
let selectedAsset = null;
|
|||
|
|
|
|||
|
|
canvas.addEventListener('mousedown', (e) => {
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
const x = e.clientX - rect.left;
|
|||
|
|
const y = e.clientY - rect.top;
|
|||
|
|
|
|||
|
|
const frame = getCurrentFrame();
|
|||
|
|
if (!frame) return;
|
|||
|
|
|
|||
|
|
// 查找点击的素材(从后往前,优先选上层的)
|
|||
|
|
selectedAsset = null;
|
|||
|
|
for (let i = frame.assets.length - 1; i >= 0; i--) {
|
|||
|
|
const asset = frame.assets[i];
|
|||
|
|
const dist = Math.sqrt((x - asset.x) ** 2 + (y - asset.y) ** 2);
|
|||
|
|
if (dist < 30) {
|
|||
|
|
selectedAsset = asset;
|
|||
|
|
// 更新选中状态
|
|||
|
|
frame.assets.forEach(a => a.selected = false);
|
|||
|
|
asset.selected = true;
|
|||
|
|
isDragging = true;
|
|||
|
|
dragStartX = x - asset.x;
|
|||
|
|
dragStartY = y - asset.y;
|
|||
|
|
renderCanvas();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没点到素材,取消所有选中
|
|||
|
|
if (!selectedAsset) {
|
|||
|
|
frame.assets.forEach(a => a.selected = false);
|
|||
|
|
renderCanvas();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
canvas.addEventListener('mousemove', (e) => {
|
|||
|
|
if (!isDragging || !selectedAsset) return;
|
|||
|
|
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
const x = e.clientX - rect.left;
|
|||
|
|
const y = e.clientY - rect.top;
|
|||
|
|
|
|||
|
|
selectedAsset.x = x - dragStartX;
|
|||
|
|
selectedAsset.y = y - dragStartY;
|
|||
|
|
|
|||
|
|
// 边界限制
|
|||
|
|
selectedAsset.x = Math.max(30, Math.min(canvas.width - 30, selectedAsset.x));
|
|||
|
|
selectedAsset.y = Math.max(30, Math.min(canvas.height - 30, selectedAsset.y));
|
|||
|
|
|
|||
|
|
renderCanvas();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
canvas.addEventListener('mouseup', () => {
|
|||
|
|
if (isDragging) {
|
|||
|
|
isDragging = false;
|
|||
|
|
saveToStorage();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 键盘删除
|
|||
|
|
document.addEventListener('keydown', (e) => {
|
|||
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|||
|
|
const frame = getCurrentFrame();
|
|||
|
|
if (!frame) return;
|
|||
|
|
|
|||
|
|
const selectedIndex = frame.assets.findIndex(a => a.selected);
|
|||
|
|
if (selectedIndex !== -1) {
|
|||
|
|
frame.assets.splice(selectedIndex, 1);
|
|||
|
|
renderCanvas();
|
|||
|
|
saveToStorage();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 导入文件监听
|
|||
|
|
document.getElementById('import-file').addEventListener('change', handleImport);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addAssetToCanvas(emoji, x, y) {
|
|||
|
|
const frame = getCurrentFrame();
|
|||
|
|
if (!frame) return;
|
|||
|
|
|
|||
|
|
// 取消其他选中
|
|||
|
|
frame.assets.forEach(a => a.selected = false);
|
|||
|
|
|
|||
|
|
const newAsset = {
|
|||
|
|
id: appState.nextAssetId++,
|
|||
|
|
type: 'emoji',
|
|||
|
|
emoji: emoji,
|
|||
|
|
x: x,
|
|||
|
|
y: y,
|
|||
|
|
selected: true
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
frame.assets.push(newAsset);
|
|||
|
|
renderCanvas();
|
|||
|
|
saveToStorage();
|
|||
|
|
}// ==================== 导出导入 ====================
|
|||
|
|
function exportScene() {
|
|||
|
|
const dataStr = JSON.stringify(appState, null, 2);
|
|||
|
|
const blob = new Blob([dataStr], { type: 'application/json' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = `hololake-scene-${Date.now()}.json`;
|
|||
|
|
document.body.appendChild(a);
|
|||
|
|
a.click();
|
|||
|
|
document.body.removeChild(a);
|
|||
|
|
URL.revokeObjectURL(url);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function importScene() {
|
|||
|
|
document.getElementById('import-file').click();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleImport(e) {
|
|||
|
|
const file = e.target.files[0];
|
|||
|
|
if (!file) return;
|
|||
|
|
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = (event) => {
|
|||
|
|
try {
|
|||
|
|
const imported = JSON.parse(event.target.result);
|
|||
|
|
|
|||
|
|
// 验证数据结构
|
|||
|
|
if (!imported.scenes || !Array.isArray(imported.scenes)) {
|
|||
|
|
throw new Error('无效的数据格式');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 向后兼容处理
|
|||
|
|
imported.scenes = imported.scenes.map(scene => {
|
|||
|
|
if (!scene.frames) {
|
|||
|
|
return {
|
|||
|
|
...scene,
|
|||
|
|
currentFrameIndex: 0,
|
|||
|
|
frames: [{
|
|||
|
|
id: 1,
|
|||
|
|
assets: scene.assets || []
|
|||
|
|
}],
|
|||
|
|
nextFrameId: 2
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return scene;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!imported.playback) {
|
|||
|
|
imported.playback = { isPlaying: false, fps: 4, intervalId: null, currentFrameIndex: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
appState = imported;
|
|||
|
|
saveToStorage();
|
|||
|
|
renderSceneTabs();
|
|||
|
|
renderCanvas();
|
|||
|
|
renderTimeline();
|
|||
|
|
updatePlaybackControls();
|
|||
|
|
updateFrameCounter();
|
|||
|
|
|
|||
|
|
alert('导入成功!');
|
|||
|
|
} catch (err) {
|
|||
|
|
alert('导入失败:' + err.message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
reader.readAsText(file);
|
|||
|
|
|
|||
|
|
// 清空 input,允许重复导入同一文件
|
|||
|
|
e.target.value = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 截图导出 ====================
|
|||
|
|
function exportScreenshot() {
|
|||
|
|
// 临时取消选中状态
|
|||
|
|
const frame = getCurrentFrame();
|
|||
|
|
if (!frame) return;
|
|||
|
|
|
|||
|
|
const originalSelected = frame.assets.map(a => a.selected);
|
|||
|
|
frame.assets.forEach(a => a.selected = false);
|
|||
|
|
renderCanvas();
|
|||
|
|
|
|||
|
|
// 导出 PNG
|
|||
|
|
const link = document.createElement('a');
|
|||
|
|
link.download = `hololake-frame-${Date.now()}.png`;
|
|||
|
|
link.href = canvas.toDataURL();
|
|||
|
|
link.click();
|
|||
|
|
|
|||
|
|
// 恢复选中状态
|
|||
|
|
frame.assets.forEach((a, i) => {
|
|||
|
|
a.selected = originalSelected[i];
|
|||
|
|
});
|
|||
|
|
renderCanvas();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 启动应用 ====================
|
|||
|
|
document.addEventListener('DOMContentLoaded', init);
|
|||
|
|
|
|||
|
|
// ==================== 环节6:预览与分享功能 ====================
|
|||
|
|
|
|||
|
|
// 预览模式状态
|
|||
|
|
let previewState = {
|
|||
|
|
isPlaying: false,
|
|||
|
|
currentFrameIndex: 0,
|
|||
|
|
animationInterval: null,
|
|||
|
|
fps: 12
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 进入预览模式
|
|||
|
|
function enterPreviewMode() {
|
|||
|
|
const container = document.getElementById('preview-container');
|
|||
|
|
const previewCanvas = document.getElementById('preview-canvas');
|
|||
|
|
|
|||
|
|
// 设置预览画布尺寸
|
|||
|
|
previewCanvas.width = canvas.width;
|
|||
|
|
previewCanvas.height = canvas.height;
|
|||
|
|
|
|||
|
|
// 显示预览容器
|
|||
|
|
container.style.display = 'block';
|
|||
|
|
|
|||
|
|
// 初始化预览状态
|
|||
|
|
previewState.currentFrameIndex = 0;
|
|||
|
|
previewState.isPlaying = false;
|
|||
|
|
|
|||
|
|
// 渲染第一帧
|
|||
|
|
renderPreviewFrame();
|
|||
|
|
|
|||
|
|
// 停止编辑器的动画
|
|||
|
|
stopAnimation();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 退出预览模式
|
|||
|
|
function exitPreviewMode() {
|
|||
|
|
const container = document.getElementById('preview-container');
|
|||
|
|
container.style.display = 'none';
|
|||
|
|
|
|||
|
|
// 停止预览动画
|
|||
|
|
previewStop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 渲染预览帧
|
|||
|
|
|
|||
|
|
// 在指定画布上绘制素材
|
|||
|
|
|
|||
|
|
// 预览播放控制
|
|||
|
|
function previewPlay() {
|
|||
|
|
if (previewState.isPlaying) return;
|
|||
|
|
|
|||
|
|
previewState.isPlaying = true;
|
|||
|
|
const currentScene = scenes[currentSceneIndex];
|
|||
|
|
if (!currentScene) return;
|
|||
|
|
|
|||
|
|
const frameInterval = 1000 / previewState.fps;
|
|||
|
|
|
|||
|
|
previewState.animationInterval = setInterval(() => {
|
|||
|
|
previewState.currentFrameIndex = (previewState.currentFrameIndex + 1) % currentScene.frames.length;
|
|||
|
|
renderPreviewFrame();
|
|||
|
|
}, frameInterval);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function previewPause() {
|
|||
|
|
previewState.isPlaying = false;
|
|||
|
|
if (previewState.animationInterval) {
|
|||
|
|
clearInterval(previewState.animationInterval);
|
|||
|
|
previewState.animationInterval = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function previewStop() {
|
|||
|
|
previewPause();
|
|||
|
|
previewState.currentFrameIndex = 0;
|
|||
|
|
renderPreviewFrame();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成分享链接
|
|||
|
|
function generateShareLink() {
|
|||
|
|
// 序列化所有场景数据
|
|||
|
|
const exportData = {
|
|||
|
|
version: '1.0',
|
|||
|
|
scenes: scenes.map(scene => ({
|
|||
|
|
name: scene.name,
|
|||
|
|
frames: scene.frames.map(frame => ({
|
|||
|
|
assets: frame.assets.map(asset => ({
|
|||
|
|
type: asset.type,
|
|||
|
|
x: asset.x,
|
|||
|
|
y: asset.y,
|
|||
|
|
width: asset.width,
|
|||
|
|
height: asset.height,
|
|||
|
|
text: asset.text,
|
|||
|
|
font: asset.font,
|
|||
|
|
color: asset.color,
|
|||
|
|
src: asset.src // 图片的dataURL
|
|||
|
|
}))
|
|||
|
|
}))
|
|||
|
|
})),
|
|||
|
|
exportTime: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 编码为JSON字符串
|
|||
|
|
const jsonStr = JSON.stringify(exportData);
|
|||
|
|
|
|||
|
|
// 使用encodeURIComponent编码,适合URL
|
|||
|
|
const encoded = encodeURIComponent(jsonStr);
|
|||
|
|
|
|||
|
|
// 生成Data URL
|
|||
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}?data=${encoded}`;
|
|||
|
|
|
|||
|
|
// 显示分享对话框
|
|||
|
|
showShareDialog(shareUrl, exportData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 显示分享对话框
|
|||
|
|
function showShareDialog(url, data) {
|
|||
|
|
// 创建对话框
|
|||
|
|
const dialog = document.createElement('div');
|
|||
|
|
dialog.style.cssText = `
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
background: rgba(0,0,0,0.8);
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: center;
|
|||
|
|
z-index: 10000;
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const content = document.createElement('div');
|
|||
|
|
content.style.cssText = `
|
|||
|
|
background: #2a2a4e;
|
|||
|
|
padding: 30px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
max-width: 600px;
|
|||
|
|
width: 90%;
|
|||
|
|
color: #fff;
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
content.innerHTML = `
|
|||
|
|
<h2 style="margin-top:0;">🔗 分享链接</h2>
|
|||
|
|
<p>复制下面的链接发给朋友,他们打开就能看到你的作品:</p>
|
|||
|
|
<textarea id="share-url" style="width:100%;height:80px;margin:10px 0;padding:10px;background:#1a1a2e;color:#00ff88;border:1px solid #444;border-radius:5px;resize:none;">${url}</textarea>
|
|||
|
|
<div style="display:flex;gap:10px;margin-top:15px;">
|
|||
|
|
<button onclick="copyShareUrl()" style="flex:1;padding:10px;background:#00ff88;color:#1a1a2e;border:none;border-radius:5px;cursor:pointer;font-weight:bold;">📋 复制链接</button>
|
|||
|
|
<button onclick="closeShareDialog()" style="flex:1;padding:10px;background:#444;color:#fff;border:none;border-radius:5px;cursor:pointer;">关闭</button>
|
|||
|
|
</div>
|
|||
|
|
<p style="margin-top:15px;font-size:12px;color:#888;">
|
|||
|
|
提示:链接包含完整作品数据,如果太长可能会被某些应用截断。建议导出HTML文件分享更稳定。
|
|||
|
|
</p>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
dialog.appendChild(content);
|
|||
|
|
document.body.appendChild(dialog);
|
|||
|
|
|
|||
|
|
// 保存对话框引用
|
|||
|
|
window.shareDialog = dialog;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 复制分享链接
|
|||
|
|
function copyShareUrl() {
|
|||
|
|
const textarea = document.getElementById('share-url');
|
|||
|
|
textarea.select();
|
|||
|
|
document.execCommand('copy');
|
|||
|
|
alert('链接已复制到剪贴板!');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 关闭分享对话框
|
|||
|
|
function closeShareDialog() {
|
|||
|
|
if (window.shareDialog) {
|
|||
|
|
window.shareDialog.remove();
|
|||
|
|
window.shareDialog = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 导出独立HTML
|
|||
|
|
function exportHTML() {
|
|||
|
|
// 准备作品数据
|
|||
|
|
const exportData = {
|
|||
|
|
version: '1.0',
|
|||
|
|
scenes: scenes.map(scene => ({
|
|||
|
|
name: scene.name,
|
|||
|
|
frames: scene.frames.map(frame => ({
|
|||
|
|
assets: frame.assets.map(asset => ({
|
|||
|
|
type: asset.type,
|
|||
|
|
x: asset.x,
|
|||
|
|
y: asset.y,
|
|||
|
|
width: asset.width,
|
|||
|
|
height: asset.height,
|
|||
|
|
text: asset.text,
|
|||
|
|
font: asset.font,
|
|||
|
|
color: asset.color,
|
|||
|
|
src: asset.src
|
|||
|
|
}))
|
|||
|
|
}))
|
|||
|
|
})),
|
|||
|
|
exportTime: new Date().toISOString(),
|
|||
|
|
exporter: 'HoloLake Dynamic Comic Studio'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 生成完整的HTML文件内容
|
|||
|
|
const htmlContent = generateStandaloneHTML(exportData);
|
|||
|
|
|
|||
|
|
// 创建下载
|
|||
|
|
const blob = new Blob([htmlContent], { type: 'text/html' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const link = document.createElement('a');
|
|||
|
|
link.download = `hololake-comic-${Date.now()}.html`;
|
|||
|
|
link.href = url;
|
|||
|
|
link.click();
|
|||
|
|
|
|||
|
|
// 清理
|
|||
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成独立HTML文件内容
|
|||
|
|
function generateStandaloneHTML(data) {
|
|||
|
|
return `<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>HoloLake 动态漫 - ${data.scenes[0]?.name || '未命名作品'}</title>
|
|||
|
|
<style>
|
|||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|||
|
|
body {
|
|||
|
|
background: #1a1a2e;
|
|||
|
|
color: #fff;
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
padding: 20px;
|
|||
|
|
}
|
|||
|
|
h1 { margin-bottom: 20px; color: #00ff88; }
|
|||
|
|
#canvas-container {
|
|||
|
|
background: #2a2a4e;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 20px;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
canvas { display: block; background: #1a1a2e; border-radius: 5px; }
|
|||
|
|
.controls {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 15px;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
button {
|
|||
|
|
padding: 12px 24px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
background: #00ff88;
|
|||
|
|
color: #1a1a2e;
|
|||
|
|
font-weight: bold;
|
|||
|
|
transition: opacity 0.2s;
|
|||
|
|
}
|
|||
|
|
button:hover { opacity: 0.8; }
|
|||
|
|
button:disabled { background: #444; cursor: not-allowed; }
|
|||
|
|
.scene-selector {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 10px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
justify-content: center;
|
|||
|
|
max-width: 800px;
|
|||
|
|
}
|
|||
|
|
.scene-btn {
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
background: #2a2a4e;
|
|||
|
|
color: #fff;
|
|||
|
|
border: 1px solid #00ff88;
|
|||
|
|
}
|
|||
|
|
.scene-btn.active { background: #00ff88; color: #1a1a2e; }
|
|||
|
|
.info { margin-top: 20px; color: #888; font-size: 14px; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<h1>🎬 HoloLake 动态漫</h1>
|
|||
|
|
<div id="canvas-container">
|
|||
|
|
<canvas id="player" width="800" height="600"></canvas>
|
|||
|
|
</div>
|
|||
|
|
<div class="controls">
|
|||
|
|
<button onclick="player.stop()">⏹ 停止</button>
|
|||
|
|
<button onclick="player.play()">▶ 播放</button>
|
|||
|
|
<button onclick="player.pause()">⏸ 暂停</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="scene-selector" id="scene-buttons"></div>
|
|||
|
|
<div class="info">
|
|||
|
|
<p>作品:${data.scenes[0]?.name || '未命名'} | 场景数:${data.scenes.length} | 导出时间:${new Date(data.exportTime).toLocaleString()}</p>
|
|||
|
|
<p>使用 HoloLake 动态漫制作系统创作</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// 作品数据
|
|||
|
|
const comicData = ${JSON.stringify(data)};
|
|||
|
|
|
|||
|
|
// 播放器
|
|||
|
|
const player = {
|
|||
|
|
canvas: document.getElementById('player'),
|
|||
|
|
ctx: null,
|
|||
|
|
currentScene: 0,
|
|||
|
|
currentFrame: 0,
|
|||
|
|
isPlaying: false,
|
|||
|
|
interval: null,
|
|||
|
|
fps: 12,
|
|||
|
|
images: {},
|
|||
|
|
|
|||
|
|
init() {
|
|||
|
|
this.ctx = this.canvas.getContext('2d');
|
|||
|
|
this.loadImages().then(() => {
|
|||
|
|
this.render();
|
|||
|
|
this.createSceneButtons();
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadImages() {
|
|||
|
|
const promises = [];
|
|||
|
|
comicData.scenes.forEach((scene, sIdx) => {
|
|||
|
|
scene.frames.forEach((frame, fIdx) => {
|
|||
|
|
frame.assets.forEach((asset, aIdx) => {
|
|||
|
|
if (asset.type === 'image' && asset.src) {
|
|||
|
|
promises.push(new Promise((resolve) => {
|
|||
|
|
const img = new Image();
|
|||
|
|
img.onload = () => {
|
|||
|
|
this.images[\`\${sIdx}-\${fIdx}-\${aIdx}\`] = img;
|
|||
|
|
resolve();
|
|||
|
|
};
|
|||
|
|
img.src = asset.src;
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
await Promise.all(promises);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
render() {
|
|||
|
|
const scene = comicData.scenes[this.currentScene];
|
|||
|
|
if (!scene) return;
|
|||
|
|
|
|||
|
|
const frame = scene.frames[this.currentFrame];
|
|||
|
|
if (!frame) return;
|
|||
|
|
|
|||
|
|
// 清空
|
|||
|
|
this.ctx.fillStyle = '#1a1a2e';
|
|||
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|||
|
|
|
|||
|
|
// 绘制素材
|
|||
|
|
frame.assets.forEach((asset, idx) => {
|
|||
|
|
if (asset.type === 'image' && asset.src) {
|
|||
|
|
const img = this.images[\`\${this.currentScene}-\${this.currentFrame}-\${idx}\`];
|
|||
|
|
if (img) {
|
|||
|
|
this.ctx.drawImage(img, asset.x, asset.y, asset.width, asset.height);
|
|||
|
|
}
|
|||
|
|
} else if (asset.type === 'text') {
|
|||
|
|
this.ctx.font = asset.font || '20px Arial';
|
|||
|
|
this.ctx.fillStyle = asset.color || '#ffffff';
|
|||
|
|
this.ctx.fillText(asset.text, asset.x, asset.y);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
play() {
|
|||
|
|
if (this.isPlaying) return;
|
|||
|
|
this.isPlaying = true;
|
|||
|
|
const scene = comicData.scenes[this.currentScene];
|
|||
|
|
|
|||
|
|
this.interval = setInterval(() => {
|
|||
|
|
this.currentFrame = (this.currentFrame + 1) % scene.frames.length;
|
|||
|
|
this.render();
|
|||
|
|
}, 1000 / this.fps);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
pause() {
|
|||
|
|
this.isPlaying = false;
|
|||
|
|
if (this.interval) {
|
|||
|
|
clearInterval(this.interval);
|
|||
|
|
this.interval = null;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
stop() {
|
|||
|
|
this.pause();
|
|||
|
|
this.currentFrame = 0;
|
|||
|
|
this.render();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
switchScene(idx) {
|
|||
|
|
this.stop();
|
|||
|
|
this.currentScene = idx;
|
|||
|
|
this.currentFrame = 0;
|
|||
|
|
this.render();
|
|||
|
|
this.updateSceneButtons();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
createSceneButtons() {
|
|||
|
|
const container = document.getElementById('scene-buttons');
|
|||
|
|
comicData.scenes.forEach((scene, idx) => {
|
|||
|
|
const btn = document.createElement('button');
|
|||
|
|
btn.className = 'scene-btn' + (idx === 0 ? ' active' : '');
|
|||
|
|
btn.textContent = scene.name || \`场景 \${idx + 1}\`;
|
|||
|
|
btn.onclick = () => this.switchScene(idx);
|
|||
|
|
container.appendChild(btn);
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
updateSceneButtons() {
|
|||
|
|
document.querySelectorAll('.scene-btn').forEach((btn, idx) => {
|
|||
|
|
btn.classList.toggle('active', idx === this.currentScene);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 启动
|
|||
|
|
player.init();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查URL参数,自动加载分享的数据
|
|||
|
|
function checkUrlParams() {
|
|||
|
|
const params = new URLSearchParams(window.location.search);
|
|||
|
|
const dataParam = params.get('data');
|
|||
|
|
|
|||
|
|
if (dataParam) {
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(decodeURIComponent(dataParam));
|
|||
|
|
if (data.scenes) {
|
|||
|
|
// 加载分享的数据
|
|||
|
|
scenes.length = 0;
|
|||
|
|
data.scenes.forEach(sceneData => {
|
|||
|
|
const newScene = {
|
|||
|
|
name: sceneData.name,
|
|||
|
|
frames: sceneData.frames.map(frameData => ({
|
|||
|
|
assets: frameData.assets.map(assetData => ({
|
|||
|
|
...assetData,
|
|||
|
|
selected: false,
|
|||
|
|
img: null
|
|||
|
|
}))
|
|||
|
|
}))
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 重新加载图片
|
|||
|
|
newScene.frames.forEach(frame => {
|
|||
|
|
frame.assets.forEach(asset => {
|
|||
|
|
if (asset.type === 'image' && asset.src) {
|
|||
|
|
const img = new Image();
|
|||
|
|
img.onload = () => {
|
|||
|
|
asset.img = img;
|
|||
|
|
renderCanvas();
|
|||
|
|
};
|
|||
|
|
img.src = asset.src;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
scenes.push(newScene);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
currentSceneIndex = 0;
|
|||
|
|
currentFrameIndex = 0;
|
|||
|
|
updateSceneList();
|
|||
|
|
updateFrameList();
|
|||
|
|
renderCanvas();
|
|||
|
|
|
|||
|
|
alert('已加载分享的作品!');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('加载分享数据失败:', e);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除URL参数
|
|||
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 页面加载时检查URL参数
|
|||
|
|
document.addEventListener('DOMContentLoaded', checkUrlParams);
|
|||
|
|
function renderPreviewFrame() {
|
|||
|
|
const previewCanvas = document.getElementById('preview-canvas');
|
|||
|
|
const ctx = previewCanvas.getContext('2d');
|
|||
|
|
const currentScene = scenes[currentSceneIndex];
|
|||
|
|
if (!currentScene || currentScene.frames.length === 0) return;
|
|||
|
|
const frame = currentScene.frames[previewState.currentFrameIndex];
|
|||
|
|
ctx.fillStyle = '#1a1a2e';
|
|||
|
|
ctx.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
|
|||
|
|
frame.assets.forEach(asset => {
|
|||
|
|
if (asset.type === 'image' && asset.img) {
|
|||
|
|
ctx.drawImage(asset.img, asset.x, asset.y, asset.width, asset.height);
|
|||
|
|
} else if (asset.type === 'text') {
|
|||
|
|
ctx.font = asset.font || '20px Arial';
|
|||
|
|
ctx.fillStyle = asset.color || '#ffffff';
|
|||
|
|
ctx.fillText(asset.text, asset.x, asset.y);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 修复版预览功能 ====================
|
|||
|
|
function renderPreviewFrame() {
|
|||
|
|
try {
|
|||
|
|
const previewCanvas = document.getElementById('preview-canvas');
|
|||
|
|
if (!previewCanvas) return;
|
|||
|
|
const ctx = previewCanvas.getContext('2d');
|
|||
|
|
const currentScene = scenes[currentSceneIndex];
|
|||
|
|
if (!currentScene || !currentScene.frames || currentScene.frames.length === 0) return;
|
|||
|
|
|
|||
|
|
const frame = currentScene.frames[previewState.currentFrameIndex];
|
|||
|
|
if (!frame || !frame.assets) return;
|
|||
|
|
|
|||
|
|
// 清空画布
|
|||
|
|
ctx.fillStyle = '#1a1a2e';
|
|||
|
|
ctx.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
|
|||
|
|
|
|||
|
|
// 绘制所有素材
|
|||
|
|
frame.assets.forEach(asset => {
|
|||
|
|
if (asset.type === 'image' && asset.img && asset.img.complete) {
|
|||
|
|
ctx.drawImage(asset.img, asset.x, asset.y, asset.width, asset.height);
|
|||
|
|
} else if (asset.type === 'text') {
|
|||
|
|
ctx.font = asset.font || '20px Arial';
|
|||
|
|
ctx.fillStyle = asset.color || '#ffffff';
|
|||
|
|
ctx.fillText(asset.text, asset.x, asset.y);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('预览渲染错误:', e);
|
|||
|
|
}
|
|||
|
|
}
|