支持动图下载和预览

This commit is contained in:
2025-10-13 15:43:18 +08:00
parent e85f959fa6
commit 5be8ae9520
18 changed files with 909 additions and 32 deletions
+40
View File
@@ -487,6 +487,46 @@ class ArtworkService {
throw error;
}
}
/**
* 获取Ugoira动画的ZIP文件URL
*/
async getUgoiraZipUrl(artworkId) {
try {
// 首先获取作品详情以确认是否为ugoira类型
const detailResponse = await this.makeRequest('GET', '/v1/illust/detail', { illust_id: artworkId });
const artwork = detailResponse.illust;
// 检查作品类型是否为ugoira
if (artwork.type !== 'ugoira') {
return {
success: false,
error: 'This artwork is not an ugoira animation'
};
}
// 获取ugoira元数据
const metadataResponse = await this.makeRequest('GET', '/v1/ugoira/metadata', { illust_id: artworkId });
// 返回ZIP文件URL和其他元数据
return {
success: true,
data: {
artwork_id: artworkId,
zip_urls: metadataResponse.ugoira_metadata.zip_urls,
frames: metadataResponse.ugoira_metadata.frames,
}
};
} catch (error) {
logger.error('Get ugoira zip URL error:', error.message);
logger.error('Get ugoira zip URL error details:', error.response?.data);
return {
success: false,
error: error.message || 'Failed to get ugoira zip URL'
};
}
}
}
module.exports = ArtworkService;
+28
View File
@@ -1,5 +1,6 @@
const path = require('path');
const fs = require('fs-extra');
const { generatePreviewGifFromUgoira } = require('./ugoira-gif');
const { defaultLogger } = require('../utils/logger');
const abortControllerManager = require('../utils/abort-controller-manager');
@@ -172,6 +173,31 @@ class DownloadExecutor {
const infoPath = path.join(artworkDir, 'artwork_info.json');
await fs.writeJson(infoPath, artwork, { spaces: 2 });
// 若为ugoira,基于已下载的ZIP生成预览GIF(不影响注册表判定)
try {
if (artwork && artwork.type === 'ugoira' && Array.isArray(artwork.ugoira_frames) && artwork.ugoira_frames.length) {
const files = await fs.readdir(artworkDir);
const zipFile = files.find((f) => f.toLowerCase().endsWith('.zip'));
if (zipFile) {
const zipPath = path.join(artworkDir, zipFile);
const previewGifPath = path.join(artworkDir, 'preview.gif');
await generatePreviewGifFromUgoira(zipPath, artwork.ugoira_frames, previewGifPath);
// 完整性校验预览GIF(不计入任务下载计数)
const integrity = await this.fileManager.checkFileIntegrity(previewGifPath, null, 'image/gif');
if (!integrity.valid) {
// 如果GIF生成不完整,尝试删除以避免残留
await this.fileManager.safeDeleteFile(previewGifPath).catch(() => {});
logger.warn('预览GIF完整性校验失败,已清理', { previewGifPath, reason: integrity.reason });
} else {
logger.info('已生成ugoira预览GIF', { previewGifPath });
}
}
}
} catch (gifError) {
logger.warn('生成ugoira预览GIF失败(继续任务流程)', { error: gifError.message });
}
// 更新任务状态 - 确保所有文件都处理完成后再更新
task.status = task.failed_files === 0 ? 'completed' : 'partial';
task.end_time = new Date();
@@ -501,6 +527,8 @@ class DownloadExecutor {
return 'image/webp';
case 'bmp':
return 'image/bmp';
case 'zip':
return 'application/zip';
default:
return 'image/jpeg'; // 默认为JPEG
}
+85 -16
View File
@@ -7,6 +7,7 @@ const ProgressManager = require('./progress-manager');
const HistoryManager = require('./history-manager');
const DownloadExecutor = require('./download-executor');
const DownloadRegistry = require('./download-registry');
const { generatePreviewGifFromUgoira } = require('./ugoira-gif');
const CacheConfigManager = require('../config/cache-config');
const fs = require('fs-extra'); // Added for fs-extra
const { defaultLogger } = require('../utils/logger');
@@ -655,6 +656,8 @@ class DownloadService {
return 'image/webp';
case 'bmp':
return 'image/bmp';
case 'zip':
return 'application/zip';
default:
return 'image/jpeg'; // 默认为JPEG
}
@@ -959,14 +962,36 @@ class DownloadService {
await this.fileManager.ensureDirectory(artworkDir);
// 获取图片URL
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
if (!imagesResult.success) {
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
// 根据作品类型获取下载资源
let images = [];
if (artwork.type === 'ugoira') {
// Ugoira动图:仅下载ZIP,预览将由本地生成GIF
const zipResult = await this.artworkService.getUgoiraZipUrl(artworkId);
if (!zipResult.success) {
throw new Error(`获取ugoira ZIP失败: ${zipResult.error}`);
}
const zipUrls = zipResult.data?.zip_urls || {};
const zipUrl = zipUrls.medium || zipUrls.large || zipUrls.small || Object.values(zipUrls)[0];
if (!zipUrl) {
throw new Error('未找到ugoira ZIP地址');
}
// 仅添加ZIP到下载列表
images = [
{ original: zipUrl, large: zipUrl, medium: zipUrl, square_medium: zipUrl },
];
// 保存ugoira帧元数据,供执行器生成预览GIF
if (Array.isArray(zipResult.data?.frames)) {
artwork.ugoira_frames = zipResult.data.frames;
}
} else {
// 普通插画/漫画:按原有逻辑获取图片URL
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
if (!imagesResult.success) {
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
}
images = imagesResult.data.images;
}
const images = imagesResult.data.images;
// 创建任务记录
const task = this.taskManager.createTask('artwork', {
artwork_id: artworkId,
@@ -1094,14 +1119,33 @@ class DownloadService {
await this.fileManager.ensureDirectory(artworkDir);
// 获取图片URL
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
if (!imagesResult.success) {
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
// 根据作品类型获取下载资源(批量下载场景)
let images = [];
if (artwork.type === 'ugoira') {
const zipResult = await this.artworkService.getUgoiraZipUrl(artworkId);
if (!zipResult.success) {
throw new Error(`获取ugoira ZIP失败: ${zipResult.error}`);
}
const zipUrls = zipResult.data?.zip_urls || {};
const zipUrl = zipUrls.medium || zipUrls.large || zipUrls.small || Object.values(zipUrls)[0];
if (!zipUrl) {
throw new Error('未找到ugoira ZIP地址');
}
// 仅添加ZIP到下载列表;预览将由本地生成GIF
images = [
{ original: zipUrl, large: zipUrl, medium: zipUrl, square_medium: zipUrl },
];
if (Array.isArray(zipResult.data?.frames)) {
artwork.ugoira_frames = zipResult.data.frames;
}
} else {
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
if (!imagesResult.success) {
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
}
images = imagesResult.data.images;
}
const images = imagesResult.data.images;
// 直接下载,不创建新任务
const results = [];
for (let index = 0; index < images.length; index++) {
@@ -1165,6 +1209,30 @@ class DownloadService {
const infoPath = path.join(artworkDir, 'artwork_info.json');
await fs.writeJson(infoPath, artwork, { spaces: 2 });
// 若为ugoira,生成预览GIF(不影响注册表判定)
try {
if (artwork && artwork.type === 'ugoira' && Array.isArray(artwork.ugoira_frames) && artwork.ugoira_frames.length) {
const files = await fs.readdir(artworkDir);
const zipFile = files.find((f) => f.toLowerCase().endsWith('.zip'));
if (zipFile) {
const zipPath = path.join(artworkDir, zipFile);
const previewGifPath = path.join(artworkDir, 'preview.gif');
await generatePreviewGifFromUgoira(zipPath, artwork.ugoira_frames, previewGifPath);
// 校验GIF完整性(不计入images数量)
const headerCheck = await this.fileManager.checkFileHeader(previewGifPath, 'image/gif');
if (!headerCheck.valid) {
await this.fileManager.safeDeleteFile(previewGifPath).catch(() => {});
logger.warn('批量生成的预览GIF完整性校验失败,已清理', { previewGifPath, reason: headerCheck.reason });
} else {
logger.info('批量已生成ugoira预览GIF', { previewGifPath });
}
}
}
} catch (gifError) {
logger.warn('批量生成ugoira预览GIF失败(继续流程)', { error: gifError.message });
}
// 检查下载结果
const failedCount = results.filter(r => !r.success).length;
const successCount = results.filter(r => r.success && !r.skipped).length;
@@ -1190,10 +1258,11 @@ class DownloadService {
break;
}
// 检查MIME类型 - 使用checkFileHeader方法来检测文件类型
const headerCheck = await this.fileManager.checkFileHeader(filePath);
if (!headerCheck.valid || !headerCheck.detectedType || !headerCheck.detectedType.startsWith('image/')) {
logger.warn(`文件MIME类型检查失败: ${filePath}, 检测结果: ${JSON.stringify(headerCheck)}`);
// 检查MIME类型 - 依据扩展名设置期望类型,支持ZIP
const expectedMimeType = this.getMimeTypeFromExtension(fileName);
const headerCheck = await this.fileManager.checkFileHeader(filePath, expectedMimeType);
if (!headerCheck.valid) {
logger.warn(`文件MIME类型或头部检查失败: ${filePath}, 检测结果: ${JSON.stringify(headerCheck)}`);
integrityCheckPassed = false;
break;
}
+13 -1
View File
@@ -168,6 +168,8 @@ class FileManager {
if (expectedMimeType) {
if (expectedMimeType.startsWith('image/')) {
return 1024; // 图片文件至少1KB
} else if (expectedMimeType.includes('zip')) {
return 1024; // ZIP文件至少1KB
}
}
@@ -243,6 +245,16 @@ class FileManager {
return { valid: true, detectedType: 'image/webp' };
}
// 非图片类型的文件头检查(例如ZIP)
if (expectedMimeType && expectedMimeType.includes('zip')) {
// ZIP文件头常见为 504B0304 或 504B0506 等,以 504B 开头
const headerHex = buffer.toString('hex', 0, Math.min(bytesRead, 4));
if (headerHex.startsWith('504b')) {
return { valid: true, detectedType: 'application/zip' };
}
return { valid: false, reason: '文件格式不匹配:期望ZIP但未检测到ZIP头部' };
}
// 如果没有明确的期望类型,且检测到了有效的图片头部,则认为有效
if (!expectedMimeType) {
return { valid: true, detectedType: 'unknown' };
@@ -250,7 +262,7 @@ class FileManager {
// 如果有期望类型但未匹配到已知格式,可能是损坏的文件
return { valid: false, reason: '无法识别的文件格式或文件头部损坏' };
} finally {
await fs.close(fd);
}
+80
View File
@@ -0,0 +1,80 @@
const fs = require('fs');
const path = require('path');
const fsExtra = require('fs-extra');
// These dependencies are expected in project dependencies
const AdmZip = require('adm-zip');
const jpeg = require('jpeg-js');
const GIFEncoder = require('gifencoder');
/**
* Generate an animated GIF preview from a Pixiv ugoira ZIP and frame metadata.
* - Extracts frames from ZIP into a temp directory
* - Encodes frames into GIF honoring per-frame delays
* - Writes GIF to outPath
*
* @param {string} zipPath - Path to the downloaded ugoira ZIP file
* @param {Array<{file:string, delay:number}>} frames - Frame metadata from Pixiv API
* @param {string} outPath - Destination path for the generated GIF (e.g., preview.gif)
*/
async function generatePreviewGifFromUgoira(zipPath, frames, outPath) {
if (!zipPath || !Array.isArray(frames) || frames.length === 0 || !outPath) {
throw new Error('Invalid parameters for generating ugoira preview GIF');
}
const tmpDir = path.join(path.dirname(outPath), '.ugoira_tmp');
await fsExtra.ensureDir(tmpDir);
try {
// Extract all frames
const zip = new AdmZip(zipPath);
zip.extractAllTo(tmpDir, true);
// Determine size from first frame
const firstFramePath = path.join(tmpDir, frames[0].file);
const firstBuf = fs.readFileSync(firstFramePath);
const firstDecoded = jpeg.decode(firstBuf, { useTArray: true });
const width = firstDecoded.width;
const height = firstDecoded.height;
// Initialize encoder and output stream
const encoder = new GIFEncoder(width, height);
const ws = fs.createWriteStream(outPath);
encoder.createReadStream().pipe(ws);
encoder.start();
encoder.setRepeat(0); // loop forever
encoder.setQuality(10);
// Add frames honoring per-frame delay
for (const f of frames) {
const framePath = path.join(tmpDir, f.file);
const buf = fs.readFileSync(framePath);
const decoded = jpeg.decode(buf, { useTArray: true });
// Ensure dimensions match; if not, skip or resize (skip for simplicity)
if (decoded.width !== width || decoded.height !== height) {
// Skip mismatched frames to keep encoder stable
continue;
}
encoder.setDelay(typeof f.delay === 'number' ? f.delay : 0);
encoder.addFrame(decoded.data);
}
encoder.finish();
await new Promise((resolve, reject) => {
ws.on('finish', resolve);
ws.on('error', reject);
});
} finally {
// Cleanup extracted frames
try { await fsExtra.remove(tmpDir); } catch (_) {}
}
}
module.exports = {
generatePreviewGifFromUgoira,
};