export-view.js 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217
  1. /**
  2. * 导出动画弹出框
  3. */
  4. class ExportView {
  5. constructor() {
  6. this.overlay = null;
  7. this.modal = null;
  8. this.previewImage = null;
  9. this.previewPlaceholder = null;
  10. this.referenceBox = null;
  11. this.referenceInput = null;
  12. this.referenceUploadArea = null;
  13. this.referenceImage = null;
  14. this.referenceImageWrapper = null;
  15. this.referenceRemoveBtn = null;
  16. this.replaceBtn = null;
  17. this.additionalPromptInput = null;
  18. this.cancelBtn = null;
  19. this.confirmBtn = null;
  20. this.imageData = null;
  21. this.referenceImageData = null;
  22. this.spritesheetCanvas = null;
  23. this.folderName = null;
  24. this.spritesheetLayout = null; // 保存布局信息用于生成 JSON
  25. this.replacedImageData = null; // 保存 Gemini 返回的替换后的图片(base64)
  26. this.geminiOriginalImageData = null; // 保存 Gemini 返回的原始图片(未抠图)
  27. this.originalSpritesheetData = null; // 保存原始 spritesheet 的 base64 数据
  28. // 下载确认对话框相关
  29. this.downloadConfirmOverlay = null;
  30. this.downloadConfirmClose = null;
  31. this.downloadOptions = null;
  32. this.init();
  33. }
  34. init() {
  35. this.overlay = document.getElementById('exportOverlay');
  36. this.modal = document.getElementById('exportModal');
  37. this.previewImage = document.getElementById('previewImage');
  38. this.previewPlaceholder = document.getElementById('previewPlaceholder');
  39. this.referenceBox = document.getElementById('referenceBox');
  40. this.referenceInput = document.getElementById('referenceInput');
  41. this.referenceUploadArea = document.getElementById('referenceUploadArea');
  42. this.referenceImage = document.getElementById('referenceImage');
  43. this.referenceImageWrapper = document.getElementById('referenceImageWrapper');
  44. this.referenceRemoveBtn = document.getElementById('referenceRemoveBtn');
  45. this.replaceBtn = document.getElementById('replaceBtn');
  46. this.additionalPromptInput = document.getElementById('additionalPromptInput');
  47. this.cancelBtn = document.getElementById('exportCancelBtn');
  48. this.confirmBtn = document.getElementById('exportConfirmBtn');
  49. // 下载确认对话框元素
  50. this.downloadConfirmOverlay = document.getElementById('downloadConfirmOverlay');
  51. this.downloadConfirmClose = document.getElementById('downloadConfirmClose');
  52. this.downloadOptions = document.querySelectorAll('.download-option');
  53. // 确保对话框初始状态是隐藏的
  54. if (this.downloadConfirmOverlay) {
  55. this.downloadConfirmOverlay.style.display = 'none';
  56. }
  57. this.bindEvents();
  58. // 初始时禁用确定按钮
  59. if (this.confirmBtn) {
  60. this.confirmBtn.disabled = true;
  61. }
  62. this.reset();
  63. }
  64. bindEvents() {
  65. // 取消按钮(右上角)
  66. this.cancelBtn?.addEventListener('click', () => {
  67. this.close();
  68. });
  69. // 取消按钮(底部操作栏)
  70. this.cancelBtnBottom?.addEventListener('click', () => {
  71. this.close();
  72. });
  73. // 确定按钮
  74. this.confirmBtn?.addEventListener('click', () => {
  75. this.handleConfirm();
  76. });
  77. // 替换按钮
  78. this.replaceBtn?.addEventListener('click', () => {
  79. this.replaceCharacter();
  80. });
  81. // 删除参考图按钮
  82. this.referenceRemoveBtn?.addEventListener('click', (e) => {
  83. e.stopPropagation(); // 阻止触发父元素的点击事件
  84. this.removeReferenceImage();
  85. });
  86. // 点击遮罩层关闭
  87. this.overlay?.addEventListener('click', (e) => {
  88. if (e.target === this.overlay) {
  89. this.close();
  90. }
  91. });
  92. // ESC键关闭
  93. document.addEventListener('keydown', (e) => {
  94. if (e.key === 'Escape') {
  95. if (this.downloadConfirmOverlay && this.downloadConfirmOverlay.style.display !== 'none') {
  96. this.hideDownloadConfirm();
  97. } else if (this.overlay) {
  98. this.close();
  99. }
  100. }
  101. });
  102. // 下载确认对话框事件
  103. this.downloadConfirmClose?.addEventListener('click', () => {
  104. this.hideDownloadConfirm();
  105. });
  106. // 点击遮罩层关闭下载确认对话框
  107. this.downloadConfirmOverlay?.addEventListener('click', (e) => {
  108. if (e.target === this.downloadConfirmOverlay) {
  109. this.hideDownloadConfirm();
  110. }
  111. });
  112. // 下载选项点击事件
  113. this.downloadOptions?.forEach(option => {
  114. option.addEventListener('click', () => {
  115. const downloadType = option.dataset.option;
  116. this.handleDownloadOption(downloadType);
  117. });
  118. });
  119. // 参考图上传区域点击
  120. this.referenceBox?.addEventListener('click', () => {
  121. this.referenceInput?.click();
  122. });
  123. // 参考图选择
  124. this.referenceInput?.addEventListener('change', (e) => {
  125. const file = e.target.files[0];
  126. if (file) {
  127. this.loadReferenceImage(file);
  128. }
  129. });
  130. // 拖拽上传参考图
  131. this.referenceBox?.addEventListener('dragover', (e) => {
  132. e.preventDefault();
  133. e.stopPropagation();
  134. if (this.referenceBox) {
  135. this.referenceBox.style.borderColor = '#667eea';
  136. }
  137. });
  138. this.referenceBox?.addEventListener('dragleave', (e) => {
  139. e.preventDefault();
  140. e.stopPropagation();
  141. if (this.referenceBox) {
  142. this.referenceBox.style.borderColor = '#e5e7eb';
  143. }
  144. });
  145. this.referenceBox?.addEventListener('drop', (e) => {
  146. e.preventDefault();
  147. e.stopPropagation();
  148. if (this.referenceBox) {
  149. this.referenceBox.style.borderColor = '#e5e7eb';
  150. }
  151. const file = e.dataTransfer.files[0];
  152. if (file && file.type.startsWith('image/')) {
  153. this.loadReferenceImage(file);
  154. }
  155. });
  156. // 监听来自父窗口的消息
  157. window.addEventListener('message', (event) => {
  158. if (event.data && event.data.type === 'show-export-preview') {
  159. // console.log('[ExportView] 收到显示预览消息:', event.data);
  160. this.showPreview(event.data.imageUrl || event.data.imageData);
  161. } else if (event.data && event.data.type === 'generate-export-preview') {
  162. // console.log('[ExportView] 收到生成预览消息:', event.data);
  163. this.reset();
  164. this.folderName = event.data.folderName;
  165. this.generatePreview(event.data.folderName);
  166. }
  167. });
  168. }
  169. /**
  170. * 生成预览图
  171. * @param {string} folderName - 文件夹名称
  172. */
  173. async generatePreview(folderName) {
  174. if (!folderName) {
  175. // console.warn('[ExportView] 没有提供文件夹名称');
  176. if (this.previewPlaceholder) {
  177. this.previewPlaceholder.textContent = '没有提供文件夹名称';
  178. }
  179. return;
  180. }
  181. // 重置状态(确保每次打开都是全新状态)
  182. this.reset();
  183. // 保存文件夹名称
  184. this.folderName = folderName;
  185. // 显示加载状态
  186. if (this.previewPlaceholder) {
  187. this.previewPlaceholder.classList.remove('hide');
  188. }
  189. if (this.previewImage) {
  190. this.previewImage.classList.remove('show');
  191. }
  192. try {
  193. const TEXTURE_ROOT = "http://localhost:3000/disk_data";
  194. // 获取当前登录用户名
  195. const username = this.getCurrentUsername();
  196. // 获取帧列表
  197. const encodedFolderName = encodeURIComponent(folderName);
  198. let apiUrl = `http://localhost:3000/api/frames/${encodedFolderName}`;
  199. if (username) {
  200. apiUrl += `?username=${encodeURIComponent(username)}`;
  201. }
  202. const response = await fetch(apiUrl);
  203. if (!response.ok) {
  204. // 服务端返回错误,解析错误信息并显示
  205. const errorMessage = await this.handleServerError(response, '无法获取帧列表');
  206. throw new Error(errorMessage);
  207. }
  208. const data = await response.json();
  209. const frameNumbers = data.frames || [];
  210. const fileNames = data.fileNames || [];
  211. if (frameNumbers.length === 0) {
  212. throw new Error('该文件夹中没有图片');
  213. }
  214. // 加载所有图片
  215. const images = [];
  216. for (let i = 0; i < frameNumbers.length; i++) {
  217. const frameNum = frameNumbers[i];
  218. const pathSegments = folderName.split('/').map(seg => encodeURIComponent(seg));
  219. const encodedPath = pathSegments.join('/');
  220. // 如果有文件名列表,使用实际文件名;否则使用帧号构造文件名
  221. let imgSrc;
  222. if (fileNames[i]) {
  223. // 使用实际文件名
  224. imgSrc = `${TEXTURE_ROOT}/${encodedPath}/${encodeURIComponent(fileNames[i])}`;
  225. } else {
  226. // 回退到使用帧号构造文件名
  227. const frameName = frameNum.toString().padStart(2, '0');
  228. imgSrc = `${TEXTURE_ROOT}/${encodedPath}/${frameName}.png`;
  229. }
  230. const img = await new Promise((resolve, reject) => {
  231. const image = new Image();
  232. image.crossOrigin = 'anonymous';
  233. image.onload = () => resolve(image);
  234. image.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`));
  235. image.src = imgSrc;
  236. });
  237. images.push({
  238. img: img,
  239. width: img.width,
  240. height: img.height,
  241. frameNum: frameNum
  242. });
  243. }
  244. // 计算布局(简化版,使用简单的网格布局)
  245. const frameWidth = images[0].width;
  246. const frameHeight = images[0].height;
  247. const cols = Math.ceil(Math.sqrt(images.length));
  248. const rows = Math.ceil(images.length / cols);
  249. // 创建 Canvas 并绘制
  250. const canvas = document.createElement('canvas');
  251. canvas.width = frameWidth * cols;
  252. canvas.height = frameHeight * rows;
  253. const ctx = canvas.getContext('2d');
  254. // 保存 canvas 和布局信息用于下载
  255. this.spritesheetCanvas = canvas;
  256. // 填充透明背景
  257. ctx.clearRect(0, 0, canvas.width, canvas.height);
  258. // 保存布局信息(用于生成 JSON)
  259. const layout = [];
  260. // 绘制所有图片
  261. images.forEach((item, index) => {
  262. const col = index % cols;
  263. const row = Math.floor(index / cols);
  264. const x = col * frameWidth;
  265. const y = row * frameHeight;
  266. ctx.drawImage(item.img, x, y);
  267. // 保存布局信息
  268. layout.push({
  269. x: x,
  270. y: y,
  271. width: item.width,
  272. height: item.height,
  273. frameNum: item.frameNum
  274. });
  275. });
  276. // 保存布局信息
  277. this.spritesheetLayout = {
  278. layout: layout,
  279. sheetWidth: canvas.width,
  280. sheetHeight: canvas.height
  281. };
  282. // 转换为 base64
  283. const imageUrl = await new Promise((resolve) => {
  284. canvas.toBlob((blob) => {
  285. const url = URL.createObjectURL(blob);
  286. resolve(url);
  287. }, 'image/png');
  288. });
  289. // 保存原始 spritesheet 的 base64 数据
  290. this.originalSpritesheetData = await new Promise((resolve) => {
  291. canvas.toBlob((blob) => {
  292. const reader = new FileReader();
  293. reader.onload = () => resolve(reader.result);
  294. reader.readAsDataURL(blob);
  295. }, 'image/png');
  296. });
  297. // 显示预览图
  298. this.showPreview(imageUrl);
  299. // 如果已经有参考图,显示替换按钮
  300. if (this.referenceImageData && this.replaceBtn) {
  301. this.replaceBtn.style.display = 'block';
  302. }
  303. // 移除自动调用替换 API 的逻辑
  304. } catch (error) {
  305. // console.error('[ExportView] 生成预览图失败:', error);
  306. if (this.previewPlaceholder) {
  307. // 隐藏加载动画,显示错误信息
  308. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  309. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  310. if (spinner) spinner.style.display = 'none';
  311. if (loadingText) {
  312. loadingText.textContent = '生成预览图失败: ' + error.message;
  313. loadingText.style.color = '#ef4444';
  314. }
  315. this.previewPlaceholder.classList.remove('hide');
  316. }
  317. }
  318. }
  319. /**
  320. * 计算宽高比
  321. * @param {number} width - 宽度
  322. * @param {number} height - 高度
  323. * @returns {string} 宽高比字符串(例如:16:9)
  324. */
  325. calculateAspectRatio(width, height) {
  326. // 计算最大公约数
  327. const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
  328. const divisor = gcd(width, height);
  329. const ratioWidth = width / divisor;
  330. const ratioHeight = height / divisor;
  331. // 如果比例太大,使用简化版本
  332. if (ratioWidth > 100 || ratioHeight > 100) {
  333. // 使用小数形式
  334. const ratio = width / height;
  335. return ratio.toFixed(2) + ':1';
  336. }
  337. return `${ratioWidth}:${ratioHeight}`;
  338. }
  339. /**
  340. * 加载参考图
  341. * @param {File} file - 图片文件
  342. */
  343. loadReferenceImage(file) {
  344. const reader = new FileReader();
  345. reader.onload = (e) => {
  346. this.referenceImageData = e.target.result;
  347. if (this.referenceImage) {
  348. this.referenceImage.src = e.target.result;
  349. }
  350. if (this.referenceImageWrapper) {
  351. this.referenceImageWrapper.style.display = 'flex';
  352. }
  353. if (this.referenceUploadArea) {
  354. this.referenceUploadArea.classList.add('hide');
  355. }
  356. // 显示替换按钮和提示词配置区域(如果已经有 spritesheet)
  357. if (this.originalSpritesheetData) {
  358. if (this.replaceBtn) {
  359. this.replaceBtn.style.display = 'block';
  360. }
  361. // 显示提示词配置区域
  362. const promptConfigSection = document.getElementById('promptConfigSection');
  363. if (promptConfigSection) {
  364. promptConfigSection.style.display = 'flex';
  365. }
  366. }
  367. // 移除自动调用替换 API 的逻辑
  368. };
  369. reader.readAsDataURL(file);
  370. }
  371. /**
  372. * 删除参考图
  373. */
  374. removeReferenceImage() {
  375. // 清空参考图数据
  376. this.referenceImageData = null;
  377. this.replacedImageData = null; // 同时清空替换后的图片
  378. // 隐藏参考图,显示上传区域
  379. if (this.referenceImageWrapper) {
  380. this.referenceImageWrapper.style.display = 'none';
  381. }
  382. if (this.referenceImage) {
  383. this.referenceImage.src = '';
  384. }
  385. if (this.referenceUploadArea) {
  386. this.referenceUploadArea.classList.remove('hide');
  387. }
  388. if (this.referenceInput) {
  389. this.referenceInput.value = '';
  390. }
  391. // 隐藏替换按钮和提示词配置区域
  392. if (this.replaceBtn) {
  393. this.replaceBtn.style.display = 'none';
  394. this.replaceBtn.disabled = false;
  395. }
  396. const promptConfigSection = document.getElementById('promptConfigSection');
  397. if (promptConfigSection) {
  398. promptConfigSection.style.display = 'none';
  399. }
  400. // 如果预览图是替换后的图片,恢复显示原始 spritesheet
  401. if (this.replacedImageData && this.originalSpritesheetData) {
  402. // 清空替换后的图片,恢复显示原始预览
  403. this.replacedImageData = null;
  404. // 重新显示原始 spritesheet
  405. if (this.spritesheetCanvas) {
  406. this.spritesheetCanvas.toBlob((blob) => {
  407. const url = URL.createObjectURL(blob);
  408. this.showPreview(url);
  409. }, 'image/png');
  410. }
  411. }
  412. }
  413. /**
  414. * 调用角色替换 API
  415. */
  416. async replaceCharacter() {
  417. if (!this.originalSpritesheetData || !this.referenceImageData) {
  418. this.showAlert('请先上传参考图和生成 Spritesheet');
  419. return;
  420. }
  421. // 禁用替换按钮和下载按钮
  422. if (this.replaceBtn) {
  423. this.replaceBtn.disabled = true;
  424. }
  425. if (this.confirmBtn) {
  426. this.confirmBtn.disabled = true;
  427. }
  428. try {
  429. // 显示加载状态
  430. if (this.previewPlaceholder) {
  431. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  432. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  433. if (spinner) spinner.style.display = 'block';
  434. if (loadingText) {
  435. loadingText.textContent = '正在生成替换后的图片...';
  436. loadingText.style.color = '#6b7280';
  437. }
  438. this.previewPlaceholder.classList.remove('hide');
  439. }
  440. if (this.previewImage) {
  441. this.previewImage.classList.remove('show');
  442. }
  443. // 准备图片数据(移除 data:image/png;base64, 前缀)
  444. const image1Base64 = this.originalSpritesheetData.replace(/^data:image\/\w+;base64,/, '');
  445. const image2Base64 = this.referenceImageData.replace(/^data:image\/\w+;base64,/, '');
  446. // 获取 image1 的尺寸
  447. const image1Width = this.spritesheetLayout?.sheetWidth || 0;
  448. const image1Height = this.spritesheetLayout?.sheetHeight || 0;
  449. // 获取额外提示词
  450. const additionalPrompt = this.additionalPromptInput?.value || '';
  451. // 调用 API
  452. const response = await fetch('http://localhost:3000/api/replace-character', {
  453. method: 'POST',
  454. headers: {
  455. 'Content-Type': 'application/json'
  456. },
  457. body: JSON.stringify({
  458. image1: image1Base64,
  459. image2: image2Base64,
  460. image1Width: image1Width,
  461. image1Height: image1Height,
  462. additionalPrompt: additionalPrompt
  463. })
  464. });
  465. if (!response.ok) {
  466. const errorMessage = await this.handleServerError(response, '角色替换失败');
  467. throw new Error(errorMessage);
  468. }
  469. const result = await response.json();
  470. if (result.success && result.imageData) {
  471. // Gemini 返回的图片 base64(不进行抠图,直接保存原始图片)
  472. const geminiImageBase64 = result.imageData;
  473. // 保存 Gemini 返回的原始图片(不抠图)
  474. this.geminiOriginalImageData = `data:image/png;base64,${geminiImageBase64}`;
  475. this.replacedImageData = this.geminiOriginalImageData; // 用于显示预览
  476. // 显示原始图片(不抠图)
  477. this.showPreview(this.geminiOriginalImageData);
  478. } else {
  479. throw new Error('API 返回失败或未找到图片数据');
  480. }
  481. } catch (error) {
  482. // console.error('[ExportView] 替换角色失败:', error);
  483. if (this.previewPlaceholder) {
  484. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  485. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  486. if (spinner) spinner.style.display = 'none';
  487. if (loadingText) {
  488. loadingText.textContent = '替换失败: ' + error.message;
  489. loadingText.style.color = '#ef4444';
  490. }
  491. this.previewPlaceholder.classList.remove('hide');
  492. }
  493. } finally {
  494. // 重新启用替换按钮和下载按钮
  495. if (this.replaceBtn) {
  496. this.replaceBtn.disabled = false;
  497. }
  498. if (this.confirmBtn) {
  499. this.confirmBtn.disabled = false;
  500. }
  501. }
  502. }
  503. /**
  504. * 显示预览图(Spritesheet)
  505. * @param {string} imageUrl - 图片URL或base64数据
  506. */
  507. showPreview(imageUrl) {
  508. if (!imageUrl) {
  509. // console.warn('[ExportView] 没有提供图片数据');
  510. return;
  511. }
  512. // console.log('[ExportView] 显示预览图');
  513. // 保存图片数据
  514. this.imageData = imageUrl;
  515. // 图片加载前禁用下载按钮
  516. if (this.confirmBtn) {
  517. this.confirmBtn.disabled = true;
  518. }
  519. // 加载图片
  520. const img = new Image();
  521. img.onload = () => {
  522. if (this.previewImage) {
  523. this.previewImage.src = imageUrl;
  524. this.previewImage.classList.add('show');
  525. }
  526. if (this.previewPlaceholder) {
  527. this.previewPlaceholder.classList.add('hide');
  528. }
  529. // 图片加载完成后启用下载按钮
  530. if (this.confirmBtn) {
  531. this.confirmBtn.disabled = false;
  532. }
  533. // console.log('[ExportView] ✓ 预览图已加载');
  534. };
  535. img.onerror = () => {
  536. // 图片加载失败时禁用下载按钮
  537. if (this.confirmBtn) {
  538. this.confirmBtn.disabled = true;
  539. }
  540. // console.error('[ExportView] 图片加载失败');
  541. if (this.previewPlaceholder) {
  542. // 隐藏加载动画,显示错误信息
  543. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  544. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  545. if (spinner) spinner.style.display = 'none';
  546. if (loadingText) {
  547. loadingText.textContent = '图片加载失败';
  548. loadingText.style.color = '#ef4444';
  549. }
  550. this.previewPlaceholder.classList.remove('hide');
  551. }
  552. };
  553. img.src = imageUrl;
  554. }
  555. /**
  556. * 生成 JSON 数据
  557. * @param {string} folderName - 文件夹名称
  558. * @param {Array} layout - 布局信息数组
  559. * @param {number} sheetWidth - Spritesheet 宽度
  560. * @param {number} sheetHeight - Spritesheet 高度
  561. * @returns {string} JSON 字符串
  562. */
  563. generateJSON(folderName, layout, sheetWidth, sheetHeight) {
  564. const frames = {};
  565. layout.forEach((item, index) => {
  566. // 使用实际的帧号,确保与原始文件名一致
  567. const frameNum = item.frameNum ? item.frameNum.toString().padStart(2, '0') : (index + 1).toString().padStart(2, '0');
  568. const frameName = `${frameNum}.png`;
  569. const x = item.x;
  570. const y = item.y;
  571. const width = item.width;
  572. const height = item.height;
  573. // 标准的 TexturePacker JSON 格式,Cocos Creator 3.8 完全兼容
  574. frames[frameName] = {
  575. frame: {
  576. x: x,
  577. y: y,
  578. w: width,
  579. h: height
  580. },
  581. rotated: false,
  582. trimmed: false,
  583. spriteSourceSize: { x: 0, y: 0, w: width, h: height },
  584. sourceSize: { w: width, h: height }
  585. };
  586. });
  587. // Cocos Creator 3.8 兼容的 TexturePacker JSON 格式
  588. const json = {
  589. frames: frames,
  590. meta: {
  591. app: "https://www.codeandweb.com/texturepacker",
  592. version: "1.0",
  593. image: `${folderName}.png`,
  594. format: "RGBA8888",
  595. size: { w: sheetWidth, h: sheetHeight },
  596. scale: 1
  597. }
  598. };
  599. return JSON.stringify(json, null, 2);
  600. }
  601. /**
  602. * 将 Blob 转换为 Base64
  603. * @param {Blob} blob - Blob 对象
  604. * @returns {Promise<string>} Base64 字符串
  605. */
  606. blobToBase64(blob) {
  607. return new Promise((resolve, reject) => {
  608. const reader = new FileReader();
  609. reader.onloadend = () => {
  610. try {
  611. let base64 = reader.result;
  612. // 移除 data:image/png;base64, 前缀(如果存在)
  613. if (base64 && base64.includes(',')) {
  614. base64 = base64.split(',')[1];
  615. }
  616. if (!base64) {
  617. reject(new Error('Base64 转换失败:结果为空'));
  618. return;
  619. }
  620. resolve(base64);
  621. } catch (error) {
  622. reject(new Error(`Base64 转换失败: ${error.message}`));
  623. }
  624. };
  625. reader.onerror = () => {
  626. reject(new Error('文件读取失败'));
  627. };
  628. reader.readAsDataURL(blob);
  629. });
  630. }
  631. /**
  632. * 下载文件
  633. * @param {Blob} data - 文件数据
  634. * @param {string} filename - 文件名
  635. * @param {string} mimeType - MIME 类型
  636. */
  637. downloadFile(data, filename, mimeType) {
  638. const blob = new Blob([data], { type: mimeType });
  639. const url = URL.createObjectURL(blob);
  640. const a = document.createElement('a');
  641. a.href = url;
  642. a.download = filename;
  643. document.body.appendChild(a);
  644. a.click();
  645. document.body.removeChild(a);
  646. URL.revokeObjectURL(url);
  647. }
  648. /**
  649. * 显示下载确认对话框
  650. */
  651. showDownloadConfirm() {
  652. if (this.downloadConfirmOverlay) {
  653. console.log('[ExportView] 显示下载确认对话框');
  654. this.downloadConfirmOverlay.style.display = 'flex';
  655. } else {
  656. console.error('[ExportView] 下载确认对话框元素未找到');
  657. }
  658. }
  659. /**
  660. * 隐藏下载确认对话框
  661. */
  662. hideDownloadConfirm() {
  663. if (this.downloadConfirmOverlay) {
  664. this.downloadConfirmOverlay.style.display = 'none';
  665. }
  666. }
  667. /**
  668. * 处理下载选项选择
  669. * @param {string} downloadType - 下载类型:'original', 'normal', 'vip'
  670. */
  671. async handleDownloadOption(downloadType) {
  672. this.hideDownloadConfirm();
  673. try {
  674. // 显示加载状态(但不隐藏预览图片)
  675. // 使用全局 Loading 提示,不干扰预览图显示
  676. if (window.parent && window.parent.postMessage) {
  677. window.parent.postMessage({
  678. type: 'global-loading',
  679. action: 'show',
  680. text: downloadType === 'original' ? '正在准备下载...' : '正在处理图片...'
  681. }, '*');
  682. }
  683. let processedImageBase64;
  684. // 确定使用的图片源
  685. if (this.geminiOriginalImageData) {
  686. // 如果有 Gemini 图片,使用 Gemini 图片
  687. const geminiImageBase64 = this.geminiOriginalImageData.replace(/^data:image\/\w+;base64,/, '');
  688. if (downloadType === 'original') {
  689. // 源文件下载:直接使用原始图片
  690. processedImageBase64 = geminiImageBase64;
  691. } else if (downloadType === 'normal') {
  692. // 普通抠图:调用 rembg-matting.py
  693. const response = await fetch('http://localhost:3000/api/matting-normal', {
  694. method: 'POST',
  695. headers: {
  696. 'Content-Type': 'application/json'
  697. },
  698. body: JSON.stringify({
  699. imageBase64: geminiImageBase64
  700. })
  701. });
  702. if (!response.ok) {
  703. const errorMessage = await this.handleServerError(response, '普通抠图失败');
  704. throw new Error(errorMessage);
  705. }
  706. const result = await response.json();
  707. if (!result.success || !result.imageData) {
  708. throw new Error('普通抠图处理失败');
  709. }
  710. processedImageBase64 = result.imageData;
  711. } else if (downloadType === 'vip') {
  712. // VIP抠图:调用 BiRefNet
  713. const response = await fetch('http://localhost:3000/api/matting-vip', {
  714. method: 'POST',
  715. headers: {
  716. 'Content-Type': 'application/json'
  717. },
  718. body: JSON.stringify({
  719. imageBase64: geminiImageBase64
  720. })
  721. });
  722. if (!response.ok) {
  723. const errorMessage = await this.handleServerError(response, 'VIP抠图失败');
  724. throw new Error(errorMessage);
  725. }
  726. const result = await response.json();
  727. if (!result.success || !result.imageData) {
  728. throw new Error('VIP抠图处理失败');
  729. }
  730. processedImageBase64 = result.imageData;
  731. }
  732. } else {
  733. // 如果没有 Gemini 图片,使用原始 spritesheet
  734. const imageBlob = await new Promise((resolve, reject) => {
  735. this.spritesheetCanvas.toBlob((blob) => {
  736. if (blob) {
  737. resolve(blob);
  738. } else {
  739. reject(new Error('Canvas 转换失败'));
  740. }
  741. }, 'image/png');
  742. });
  743. // 将图片转换为 Base64
  744. const originalImageBase64 = await this.blobToBase64(imageBlob);
  745. if (downloadType === 'original') {
  746. // 源文件下载:直接使用原始 spritesheet
  747. processedImageBase64 = originalImageBase64;
  748. } else if (downloadType === 'normal') {
  749. // 普通抠图:对原始 spritesheet 进行抠图
  750. const response = await fetch('http://localhost:3000/api/matting-normal', {
  751. method: 'POST',
  752. headers: {
  753. 'Content-Type': 'application/json'
  754. },
  755. body: JSON.stringify({
  756. imageBase64: originalImageBase64
  757. })
  758. });
  759. if (!response.ok) {
  760. const errorMessage = await this.handleServerError(response, '普通抠图失败');
  761. throw new Error(errorMessage);
  762. }
  763. const result = await response.json();
  764. if (!result.success || !result.imageData) {
  765. throw new Error('普通抠图处理失败');
  766. }
  767. processedImageBase64 = result.imageData;
  768. } else if (downloadType === 'vip') {
  769. // VIP抠图:对原始 spritesheet 进行 VIP 抠图
  770. const response = await fetch('http://localhost:3000/api/matting-vip', {
  771. method: 'POST',
  772. headers: {
  773. 'Content-Type': 'application/json'
  774. },
  775. body: JSON.stringify({
  776. imageBase64: originalImageBase64
  777. })
  778. });
  779. if (!response.ok) {
  780. const errorMessage = await this.handleServerError(response, 'VIP抠图失败');
  781. throw new Error(errorMessage);
  782. }
  783. const result = await response.json();
  784. if (!result.success || !result.imageData) {
  785. throw new Error('VIP抠图处理失败');
  786. }
  787. processedImageBase64 = result.imageData;
  788. }
  789. }
  790. // 生成 JSON 数据
  791. const folderName = this.folderName.split('/').pop() || 'spritesheet';
  792. const jsonData = this.generateJSON(
  793. folderName,
  794. this.spritesheetLayout.layout,
  795. this.spritesheetLayout.sheetWidth,
  796. this.spritesheetLayout.sheetHeight
  797. );
  798. // 发送到服务器打包
  799. const response = await fetch('http://localhost:3000/api/pack', {
  800. method: 'POST',
  801. headers: {
  802. 'Content-Type': 'application/json'
  803. },
  804. body: JSON.stringify({
  805. folderName: folderName,
  806. imageData: processedImageBase64,
  807. jsonData: jsonData
  808. })
  809. });
  810. if (!response.ok) {
  811. const errorMessage = await this.handleServerError(response, '打包失败');
  812. throw new Error(errorMessage);
  813. }
  814. // 获取 ZIP 文件的 Blob
  815. const zipBlob = await response.blob();
  816. // 下载 ZIP 文件
  817. this.downloadFile(zipBlob, `${folderName}.zip`, 'application/zip');
  818. // 隐藏全局 Loading 提示
  819. if (window.parent && window.parent.postMessage) {
  820. window.parent.postMessage({
  821. type: 'global-loading',
  822. action: 'hide'
  823. }, '*');
  824. }
  825. // 下载完成后,不关闭弹出框,不刷新图片
  826. // 显示成功提示
  827. this.showAlert('下载成功!');
  828. } catch (error) {
  829. // console.error('[ExportView] 下载失败:', error);
  830. // 隐藏全局 Loading 提示
  831. if (window.parent && window.parent.postMessage) {
  832. window.parent.postMessage({
  833. type: 'global-loading',
  834. action: 'hide'
  835. }, '*');
  836. }
  837. this.showAlert(`下载失败: ${error.message}`);
  838. }
  839. }
  840. /**
  841. * 处理下载按钮点击
  842. */
  843. async handleConfirm() {
  844. // console.log('[ExportView] 用户点击下载按钮');
  845. if (!this.spritesheetCanvas || !this.spritesheetLayout) {
  846. // console.warn('[ExportView] 没有可下载的 Spritesheet');
  847. return;
  848. }
  849. // 总是显示下载确认对话框,让用户选择下载方式
  850. console.log('[ExportView] 显示下载选项对话框');
  851. this.showDownloadConfirm();
  852. }
  853. /**
  854. * 下载 Spritesheet(原始逻辑)
  855. */
  856. async downloadSpritesheet() {
  857. try {
  858. // 生成 JSON 数据
  859. const folderName = this.folderName.split('/').pop() || 'spritesheet';
  860. const jsonData = this.generateJSON(
  861. folderName,
  862. this.spritesheetLayout.layout,
  863. this.spritesheetLayout.sheetWidth,
  864. this.spritesheetLayout.sheetHeight
  865. );
  866. // 确定使用哪个图片:如果有替换后的图片,使用替换后的;否则使用原始的
  867. let imageBase64;
  868. if (this.replacedImageData) {
  869. // 使用替换后的图片(移除 data:image/png;base64, 前缀)
  870. imageBase64 = this.replacedImageData.replace(/^data:image\/\w+;base64,/, '');
  871. } else {
  872. // 使用原始 spritesheet
  873. const imageBlob = await new Promise((resolve, reject) => {
  874. this.spritesheetCanvas.toBlob((blob) => {
  875. if (blob) {
  876. resolve(blob);
  877. } else {
  878. reject(new Error('Canvas 转换失败'));
  879. }
  880. }, 'image/png');
  881. });
  882. // 将图片转换为 Base64
  883. imageBase64 = await this.blobToBase64(imageBlob);
  884. }
  885. // 验证数据
  886. if (!imageBase64 || typeof imageBase64 !== 'string' || imageBase64.trim().length === 0) {
  887. throw new Error('图片数据无效');
  888. }
  889. if (!jsonData || typeof jsonData !== 'string' || jsonData.trim().length === 0) {
  890. throw new Error('JSON 数据无效');
  891. }
  892. // 发送到服务器打包
  893. const response = await fetch('http://localhost:3000/api/pack', {
  894. method: 'POST',
  895. headers: {
  896. 'Content-Type': 'application/json'
  897. },
  898. body: JSON.stringify({
  899. folderName: folderName,
  900. imageData: imageBase64,
  901. jsonData: jsonData
  902. })
  903. });
  904. if (!response.ok) {
  905. const errorMessage = await this.handleServerError(response, '打包失败');
  906. throw new Error(errorMessage);
  907. }
  908. // 获取 ZIP 文件的 Blob
  909. const zipBlob = await response.blob();
  910. // 下载 ZIP 文件
  911. this.downloadFile(zipBlob, `${folderName}.zip`, 'application/zip');
  912. // 下载完成后,不关闭弹出框,不刷新图片
  913. // 显示成功提示
  914. this.showAlert('下载成功!');
  915. } catch (error) {
  916. // console.error('[ExportView] 下载失败:', error);
  917. this.showAlert(`下载失败: ${error.message}`);
  918. }
  919. }
  920. /**
  921. * 关闭弹出框
  922. */
  923. close() {
  924. // console.log('[ExportView] 关闭导出弹出框');
  925. // 清空所有数据
  926. this.reset();
  927. // 通知父窗口关闭弹出框
  928. if (window.parent && window.parent !== window) {
  929. window.parent.postMessage({
  930. type: 'close-export-view'
  931. }, '*');
  932. }
  933. }
  934. /**
  935. * 获取当前登录用户名
  936. */
  937. getCurrentUsername() {
  938. try {
  939. const loginDataStr = localStorage.getItem('loginData');
  940. if (!loginDataStr) {
  941. return null;
  942. }
  943. const loginData = JSON.parse(loginDataStr);
  944. const now = Date.now();
  945. // 检查是否过期
  946. if (now >= loginData.expireTime) {
  947. localStorage.removeItem('loginData');
  948. return null;
  949. }
  950. return loginData.user ? loginData.user.username : null;
  951. } catch (error) {
  952. console.error('[ExportView] 获取用户名失败:', error);
  953. return null;
  954. }
  955. }
  956. /**
  957. * 重置所有数据和UI状态
  958. */
  959. reset() {
  960. // 清空数据属性
  961. this.imageData = null;
  962. this.referenceImageData = null;
  963. this.spritesheetCanvas = null;
  964. this.folderName = null;
  965. this.spritesheetLayout = null;
  966. this.replacedImageData = null;
  967. this.geminiOriginalImageData = null;
  968. this.originalSpritesheetData = null;
  969. // 重置参考图区域
  970. if (this.referenceImageWrapper) {
  971. this.referenceImageWrapper.style.display = 'none';
  972. }
  973. if (this.referenceImage) {
  974. this.referenceImage.src = '';
  975. }
  976. if (this.referenceUploadArea) {
  977. this.referenceUploadArea.classList.remove('hide');
  978. }
  979. if (this.referenceInput) {
  980. this.referenceInput.value = '';
  981. }
  982. if (this.replaceBtn) {
  983. this.replaceBtn.style.display = 'none';
  984. this.replaceBtn.disabled = false;
  985. }
  986. // 重置预览图区域
  987. if (this.previewImage) {
  988. this.previewImage.src = '';
  989. this.previewImage.classList.remove('show');
  990. }
  991. if (this.previewPlaceholder) {
  992. const spinner = this.previewPlaceholder.querySelector('.loading-spinner');
  993. const loadingText = this.previewPlaceholder.querySelector('.loading-text');
  994. if (spinner) spinner.style.display = 'block';
  995. if (loadingText) {
  996. loadingText.textContent = '正在生成预览图...';
  997. loadingText.style.color = '#6b7280';
  998. }
  999. this.previewPlaceholder.classList.remove('hide');
  1000. }
  1001. // 重置提示词配置区域
  1002. const promptConfigSection = document.getElementById('promptConfigSection');
  1003. if (promptConfigSection) {
  1004. promptConfigSection.style.display = 'none';
  1005. }
  1006. if (this.additionalPromptInput) {
  1007. this.additionalPromptInput.value = '';
  1008. }
  1009. // 重置按钮状态
  1010. if (this.confirmBtn) {
  1011. this.confirmBtn.disabled = true;
  1012. }
  1013. }
  1014. /**
  1015. * 显示提示信息(使用全局 Alert 组件,直接调用)
  1016. * @param {string} message - 提示信息
  1017. * @param {number} duration - 显示时长(毫秒),默认2000
  1018. */
  1019. showAlert(message, duration = 2000) {
  1020. // 直接调用父窗口的 GlobalAlert(不通过 postMessage)
  1021. try {
  1022. // 优先使用父窗口的 GlobalAlert
  1023. if (window.parent && window.parent !== window && window.parent.GlobalAlert) {
  1024. window.parent.GlobalAlert.show(message, duration);
  1025. return;
  1026. }
  1027. // 如果不在 iframe 中,直接使用当前窗口的 GlobalAlert
  1028. if (window.GlobalAlert) {
  1029. window.GlobalAlert.show(message, duration);
  1030. return;
  1031. }
  1032. // 降级处理
  1033. console.log('[Alert]', message);
  1034. } catch (error) {
  1035. console.error('[ExportView] 显示 alert 失败:', error);
  1036. alert(message);
  1037. }
  1038. }
  1039. // 统一的错误处理函数:解析服务端错误响应并显示
  1040. async handleServerError(response, defaultMessage = '操作失败') {
  1041. let errorMessage = defaultMessage;
  1042. try {
  1043. // 尝试解析 JSON 错误响应
  1044. const errorData = await response.json().catch(() => null);
  1045. if (errorData) {
  1046. // 优先使用服务端返回的 message
  1047. if (errorData.message) {
  1048. errorMessage = errorData.message;
  1049. } else if (errorData.error) {
  1050. errorMessage = errorData.error;
  1051. } else if (typeof errorData === 'string') {
  1052. errorMessage = errorData;
  1053. }
  1054. }
  1055. } catch (e) {
  1056. // 如果解析失败,使用默认消息或状态码
  1057. if (response.status) {
  1058. errorMessage = `${defaultMessage} (状态码: ${response.status})`;
  1059. }
  1060. }
  1061. this.showAlert(errorMessage);
  1062. return errorMessage;
  1063. }
  1064. }
  1065. // 初始化
  1066. window.ExportView = new ExportView();