支持动图下载和预览
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user