536 lines
29 KiB
TypeScript
536 lines
29 KiB
TypeScript
import archiver from 'archiver';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import sharp from 'sharp';
|
||
import { PrismaClient } from '@prisma/client';
|
||
|
||
import { fileURLToPath } from 'url';
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
const prisma = new PrismaClient();
|
||
const STORAGE_ROOT = path.resolve(__dirname, '../../storage');
|
||
|
||
// Aspect Ratio Map for exact print dimensions
|
||
const ASPECT_RATIO_MAP: Record<string, [number, number]> = {
|
||
"1:1": [6000, 6000],
|
||
"3:4": [4500, 6000],
|
||
"4:3": [6000, 4500],
|
||
"4:5": [4800, 6000],
|
||
"5:4": [6000, 4800],
|
||
"9:16": [3375, 6000],
|
||
"16:9": [6000, 3375],
|
||
"2:3": [4000, 6000],
|
||
"3:2": [6000, 4000],
|
||
"1:4": [1500, 6000],
|
||
"4:1": [6000, 1500],
|
||
};
|
||
const MIN_PRINT_SIZE = 6000;
|
||
|
||
export class ArchiveService {
|
||
|
||
// 1. MASTER ZIP: Everything (Source of Truth)
|
||
async createMasterArchive(projectId: string, res: any) {
|
||
const project = await prisma.project.findUnique({
|
||
where: { id: projectId },
|
||
include: { assets: true, seoData: true, user: { include: { etsyShops: true } } }
|
||
});
|
||
|
||
if (!project) throw new Error("Project not found");
|
||
|
||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||
|
||
res.attachment(`MasterBundle_${project.productType}_${projectId.substring(0, 6)}.zip`);
|
||
archive.pipe(res);
|
||
|
||
const qualityLog: string[] = [];
|
||
qualityLog.push(`QUALITY VALIDATION REPORT`);
|
||
qualityLog.push(`Generated: ${new Date().toISOString()}`);
|
||
qualityLog.push(`Project ID: ${projectId}`);
|
||
qualityLog.push(`Product Type: ${project.productType}`);
|
||
qualityLog.push(`----------------------------------------`);
|
||
|
||
// A. Add All Assets & Validate (with Print-Ready Enforcement)
|
||
const MIN_PRINT_SIZE = 6000; // Minimum long side for print-ready
|
||
|
||
for (const asset of project.assets) {
|
||
const assetPath = path.join(STORAGE_ROOT, asset.path);
|
||
if (fs.existsSync(assetPath)) {
|
||
const internalPath = `assets/${asset.type}/${path.basename(assetPath)}`;
|
||
|
||
// VALIDATION & ENFORCEMENT (Only for images)
|
||
if (assetPath.endsWith('.png') || assetPath.endsWith('.jpg')) {
|
||
try {
|
||
const meta = await sharp(assetPath).metadata();
|
||
const currentWidth = meta.width || 0;
|
||
const currentHeight = meta.height || 0;
|
||
const longSide = Math.max(currentWidth, currentHeight);
|
||
const isPrintReady = longSide >= MIN_PRINT_SIZE && (meta.density || 72) >= 300;
|
||
const colorSpace = meta.channels === 4 ? "RGB+Alpha" : meta.channels === 3 ? "RGB" : "CMYK/Other";
|
||
|
||
// For MASTER and UPSCALED: Enforce 6000px @ 300 DPI
|
||
if (['master', 'upscaled'].includes(asset.type) && !isPrintReady) {
|
||
const ratio = currentWidth / currentHeight;
|
||
let finalWidth: number;
|
||
let finalHeight: number;
|
||
|
||
if (currentWidth >= currentHeight) {
|
||
finalWidth = MIN_PRINT_SIZE;
|
||
finalHeight = Math.round(MIN_PRINT_SIZE / ratio);
|
||
} else {
|
||
finalHeight = MIN_PRINT_SIZE;
|
||
finalWidth = Math.round(MIN_PRINT_SIZE * ratio);
|
||
}
|
||
|
||
// Upscale on-the-fly for archive
|
||
const upscaledBuffer = await sharp(assetPath)
|
||
.resize(finalWidth, finalHeight, { kernel: sharp.kernel.lanczos3, fit: 'fill' })
|
||
.withMetadata({ density: 300 })
|
||
.png({ quality: 100, compressionLevel: 6 })
|
||
.toBuffer();
|
||
|
||
archive.append(upscaledBuffer, { name: `assets/${asset.type}/PrintReady_${finalWidth}x${finalHeight}_${path.basename(assetPath)}` });
|
||
|
||
qualityLog.push(`[UPSCALED FOR PRINT] ${internalPath}`);
|
||
qualityLog.push(` - Original: ${currentWidth}x${currentHeight}px | Density: ${meta.density || 72}dpi`);
|
||
qualityLog.push(` - Upscaled to: ${finalWidth}x${finalHeight}px @ 300 DPI for print-readiness.`);
|
||
} else {
|
||
// Add as-is (already compliant or non-master asset)
|
||
archive.file(assetPath, { name: internalPath });
|
||
|
||
const status = isPrintReady ? "PRINT-READY ✓" : (longSide >= 2000 ? "PASS" : "WARN (Low Res)");
|
||
qualityLog.push(`[${status}] ${internalPath}`);
|
||
qualityLog.push(` - Dims: ${currentWidth}x${currentHeight}px | Density: ${meta.density || 72}dpi | Color: ${colorSpace}`);
|
||
if (!isPrintReady && ['master', 'upscaled'].includes(asset.type)) {
|
||
qualityLog.push(` - WARNING: Asset may be too small for large format printing.`);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
qualityLog.push(`[ERROR] validation failed for ${internalPath}: ${(e as Error).message}`);
|
||
// Still add the original file on error
|
||
archive.file(assetPath, { name: internalPath });
|
||
}
|
||
} else {
|
||
// Non-image assets (text files, etc.)
|
||
archive.file(assetPath, { name: internalPath });
|
||
}
|
||
} else {
|
||
qualityLog.push(`[MISSING] Asset record found but file missing: ${asset.path}`);
|
||
}
|
||
}
|
||
|
||
// B. Add Restoration Manifest (Machine Readable)
|
||
const manifest = {
|
||
version: "1.0",
|
||
timestamp: new Date().toISOString(),
|
||
project: {
|
||
id: project.id,
|
||
niche: project.niche,
|
||
productType: project.productType,
|
||
creativity: project.creativity,
|
||
aspectRatio: project.aspectRatio
|
||
},
|
||
seo: project.seoData,
|
||
assets: project.assets.map(a => ({ type: a.type, path: a.path, meta: a.meta }))
|
||
};
|
||
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
|
||
|
||
// C. Add Quality Report
|
||
archive.append(qualityLog.join('\n'), { name: 'docs/QUALITY_REPORT.txt' });
|
||
|
||
// D. Add Strategy / SEO
|
||
if (project.seoData) {
|
||
// Construct the State Object exactly how the UI components demand it
|
||
// CRITICAL FIX: Fallback to seoData if strategy is missing (Recovered Projects Scenario)
|
||
const resolvedStrategy = (project.seoData as any) || {};
|
||
const shopName = project.user?.etsyShops?.[0]?.shopName || "The Artist";
|
||
|
||
// Format Attributes
|
||
let attributesStr = "None";
|
||
try {
|
||
if ((project.seoData as any).attributes) {
|
||
const attrs = JSON.parse((project.seoData as any).attributes);
|
||
attributesStr = Object.entries(attrs).map(([k, v]) => `- ${k.toUpperCase()}: ${v}`).join('\n');
|
||
}
|
||
} catch (e) { }
|
||
|
||
const seoContent = `
|
||
TITLE: ${(project as any).sku ? `[${(project as any).sku}] ` : ''}${project.seoData.title}
|
||
SKU: ${(project as any).sku || 'Not Assigned'}
|
||
CATEGORY PATH: ${(project.seoData as any).categoryPath || "Not set"}
|
||
|
||
DESCRIPTION:
|
||
${project.seoData.description}
|
||
|
||
KEYWORDS: ${JSON.parse(project.seoData.keywords as string || '[]').join(', ')}
|
||
|
||
HIDDEN ATTRIBUTES:
|
||
${attributesStr}
|
||
|
||
--------------------------------------
|
||
Generated for ${shopName} via DigiCraft
|
||
`;
|
||
archive.append(seoContent, { name: 'docs/SEO_METADATA.txt' });
|
||
|
||
if (project.seoData.printingGuide) {
|
||
const guidePath = path.join(STORAGE_ROOT, project.seoData.printingGuide);
|
||
if (fs.existsSync(guidePath)) {
|
||
archive.file(guidePath, { name: 'docs/Printing_Guide.txt' });
|
||
}
|
||
}
|
||
}
|
||
|
||
await archive.finalize();
|
||
}
|
||
|
||
// 2. CUSTOMER ARCHIVE: Filtering sensitive info, only deliverables
|
||
async createCustomerArchive(projectId: string, mode: 'rgb' | 'cmyk', res: any) {
|
||
const project = await prisma.project.findUnique({
|
||
where: { id: projectId },
|
||
include: { assets: true, seoData: true, user: { include: { etsyShops: true } } }
|
||
});
|
||
|
||
if (!project) throw new Error("Project not found");
|
||
|
||
const shopName = (project.user as any)?.etsyShopName || project.user?.etsyShops?.[0]?.shopName || "The Artist";
|
||
const shopUrl = (project.user as any)?.etsyShopLink || "";
|
||
|
||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||
res.attachment(`CustomerDownload_${mode.toUpperCase()}_${projectId.substring(0, 6)}.zip`);
|
||
archive.pipe(res);
|
||
|
||
// ----------------------------------------------------
|
||
// A. Neuro-Marketing Experience Pack (Etsy-Compliant)
|
||
// ----------------------------------------------------
|
||
|
||
const readmeContent = `
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
✨ THANK YOU FOR CHOOSING ${shopName.toUpperCase()}! ✨
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
Congratulations on your wonderful choice! 🎨
|
||
|
||
We're so excited that you've chosen to bring this artwork into your space.
|
||
Every pixel has been crafted with care, and we can't wait to see how you use it.
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
📥 WHAT'S INSIDE THIS PACKAGE?
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
${mode === 'cmyk' ? `✓ Professional CMYK Print Files (.tif format)
|
||
→ Optimized for commercial printing
|
||
→ 300 DPI high-resolution
|
||
→ Ready for offset, laser, or inkjet printing` : `✓ Digital RGB Files (.png format)
|
||
→ Perfect for digital displays
|
||
→ 300 DPI high-resolution
|
||
→ Vibrant colors for screens and home printers`}
|
||
|
||
✓ Multiple Aspect Ratios Included:
|
||
→ 3:4 (9x12, 18x24 inches)
|
||
→ 4:5 (8x10, 16x20 inches)
|
||
→ 2:3 (4x6, 24x36 inches)
|
||
→ 5:7 / ISO (A1-A5 international)
|
||
→ 11:14 (Exclusive frame size)
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
🖨️ HOW TO PRINT (3 Easy Steps)
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
1️⃣ Choose your file based on your frame size
|
||
2️⃣ Upload to a print service (Costco, Walgreens, local print shop)
|
||
3️⃣ Select "Original Size" or "Fit to Frame" and print!
|
||
|
||
💡 Pro Tip: For the BEST quality, use a professional print service
|
||
with heavyweight matte or luster paper (200gsm+).
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
💬 WE'D LOVE TO HEAR FROM YOU
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
Your feedback helps us grow and helps other buyers make informed decisions.
|
||
If you have a moment, sharing your experience would mean the world to us.
|
||
|
||
Whether it's a quick thought or a photo of the art in your space —
|
||
we genuinely appreciate every message. 💌
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
❓ NEED HELP?
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
Having trouble? We're here for you!
|
||
Simply send us a message through Etsy and we'll respond within 24 hours.
|
||
|
||
With gratitude,
|
||
The ${shopName} Team 🌟
|
||
${shopUrl ? `\n🔗 Visit our shop: ${shopUrl}` : ''}
|
||
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
`;
|
||
archive.append(readmeContent.trim(), { name: '00_READ_ME_FIRST.txt' });
|
||
|
||
const licenseContent = `
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
📜 PERSONAL USE LICENSE AGREEMENT
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
Thank you for supporting independent artists! 🙏
|
||
This license grants you the following rights:
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
✅ WHAT YOU CAN DO
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
• Print and display this artwork in your home or office
|
||
• Use as a digital wallpaper on your personal devices
|
||
• Gift a PRINTED copy to friends or family
|
||
• Print multiple copies for your own personal spaces
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
❌ WHAT'S NOT ALLOWED
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
• Reselling, sharing, or redistributing the digital files
|
||
• Uploading to print-on-demand sites (Redbubble, Amazon, Etsy, etc.)
|
||
• Using for commercial products without a commercial license
|
||
• Claiming this artwork as your own creation
|
||
• Modifying and reselling as a "new" product
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
🛡️ COPYRIGHT NOTICE
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
This artwork is protected by copyright law.
|
||
Unauthorized distribution or commercial use is prohibited.
|
||
|
||
We put our heart into every design. Thank you for respecting
|
||
the rights of independent artists and small businesses. ❤️
|
||
|
||
© ${new Date().getFullYear()} ${shopName}. All Rights Reserved.
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
📧 QUESTIONS ABOUT LICENSING?
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
Need a commercial license? Want to use this for a business project?
|
||
Just send us a message — we're happy to discuss custom licensing options!
|
||
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
`;
|
||
archive.append(licenseContent.trim(), { name: '01_LICENSE.txt' });
|
||
|
||
// NEW: Thank You Card with Soft Review Request
|
||
const thankYouContent = `
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
💌 A PERSONAL NOTE FROM THE ARTIST
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
Hey there, art lover! 👋
|
||
|
||
I wanted to take a moment to personally thank you for your purchase.
|
||
As a small business owner, every order feels like a little celebration.
|
||
|
||
Your support allows me to keep creating, keep dreaming, and keep
|
||
bringing beautiful art into the world. That means everything.
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
🌟 SHARE YOUR EXPERIENCE
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
If your order arrived safely and you're happy with the quality,
|
||
I'd be incredibly grateful if you could take a moment to share
|
||
your experience with other buyers.
|
||
|
||
Your honest feedback — whatever it may be — helps others discover
|
||
our work and helps us understand how we can serve you better.
|
||
|
||
(No pressure, no expectations. Just genuine appreciation for
|
||
any thoughts you're willing to share. 🙏)
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
📸 WE LOVE SEEING YOUR SPACE!
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
Hung up your print? Styled it on your desk?
|
||
We'd love to see how you've decorated your space!
|
||
|
||
Tag us or send a photo — we might feature you on our page!
|
||
|
||
───────────────────────────────────────────────────────────────────────────────
|
||
|
||
Until next time,
|
||
${shopName} 🎨✨
|
||
${shopUrl ? `\n🔗 ${shopUrl}` : ''}
|
||
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
`;
|
||
archive.append(thankYouContent.trim(), { name: '02_THANK_YOU.txt' });
|
||
|
||
// ----------------------------------------------------
|
||
// B. Add Deliverables (with Print-Ready Enforcement)
|
||
// ----------------------------------------------------
|
||
|
||
// Filter: Only Master and Variants
|
||
const deliverables = project.assets.filter(a => ['master', 'variant', 'upscaled'].includes(a.type));
|
||
|
||
// Get target dimensions from project's aspect ratio
|
||
const targetRatio = project.aspectRatio || "3:4";
|
||
let targetWidth: number;
|
||
let targetHeight: number;
|
||
|
||
if (ASPECT_RATIO_MAP[targetRatio]) {
|
||
[targetWidth, targetHeight] = ASPECT_RATIO_MAP[targetRatio];
|
||
} else {
|
||
// Fallback for unknown ratios
|
||
const [w, h] = targetRatio.split(':').map(Number);
|
||
if (w > h) {
|
||
targetWidth = MIN_PRINT_SIZE;
|
||
targetHeight = Math.round(MIN_PRINT_SIZE * h / w);
|
||
} else {
|
||
targetHeight = MIN_PRINT_SIZE;
|
||
targetWidth = Math.round(MIN_PRINT_SIZE * w / h);
|
||
}
|
||
}
|
||
|
||
const nameCount: Record<string, number> = {};
|
||
|
||
for (const asset of deliverables) {
|
||
const assetPath = path.join(STORAGE_ROOT, asset.path);
|
||
if (fs.existsSync(assetPath)) {
|
||
// filename logic: SEO Title (truncated) + Ratio
|
||
let safeTitle = "Artwork";
|
||
if (project.seoData?.title) {
|
||
safeTitle = project.seoData.title
|
||
.replace(/[^a-zA-Z0-9\s-_]/g, '') // Remove special chars
|
||
.trim()
|
||
.replace(/\s+/g, '_'); // Replace spaces with underscores
|
||
|
||
if (safeTitle.length > 40) {
|
||
safeTitle = safeTitle.substring(0, 40);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const meta = await sharp(assetPath).metadata();
|
||
const currentWidth = meta.width || 0;
|
||
const currentHeight = meta.height || 0;
|
||
|
||
// Check if image is already at target specifications
|
||
const isPrintReady = (currentWidth === targetWidth && currentHeight === targetHeight);
|
||
|
||
// Determine Ratio String
|
||
let ratioStr = "Original";
|
||
// Simple GCD or lookup could be better, but approximation works for filenames
|
||
const r = currentWidth / currentHeight;
|
||
if (Math.abs(r - 3 / 4) < 0.05) ratioStr = "3x4";
|
||
else if (Math.abs(r - 4 / 5) < 0.05) ratioStr = "4x5";
|
||
else if (Math.abs(r - 2 / 3) < 0.05) ratioStr = "2x3";
|
||
else if (Math.abs(r - 9 / 16) < 0.05) ratioStr = "9x16";
|
||
else if (Math.abs(r - 5 / 7) < 0.05) ratioStr = "5x7";
|
||
else if (Math.abs(r - 11 / 14) < 0.05) ratioStr = "11x14";
|
||
else if (Math.abs(r - 1) < 0.05) ratioStr = "1x1";
|
||
else ratioStr = `${currentWidth}x${currentHeight}`;
|
||
|
||
// Dynamic Truncation Logic to preserve Suffix/SKU
|
||
const skuPrefix = (project as any).sku ? `${(project as any).sku}_` : "";
|
||
const ratioSuffix = `_${ratioStr}`;
|
||
const targetExt = mode === 'cmyk' ? '.tif' : '.png';
|
||
|
||
const MAX_FILENAME_LEN = 65; // Strict limit
|
||
const reservedLen = skuPrefix.length + ratioSuffix.length + targetExt.length;
|
||
const availableTitleLen = MAX_FILENAME_LEN - reservedLen;
|
||
|
||
let finalTitle = safeTitle;
|
||
if (finalTitle.length > availableTitleLen) {
|
||
finalTitle = finalTitle.substring(0, Math.max(10, availableTitleLen)); // Ensure at least 10 chars
|
||
}
|
||
|
||
// Construct Final Name
|
||
// Handle duplicates by pre-checking against nameCount map?
|
||
// Actually, simple counter logic needs to happen BEFORE full truncation or AFTER?
|
||
// Better to rely on unique ratioStr for master assets.
|
||
// But if multiple assets map to same ratio (e.g. variants), we need index.
|
||
|
||
let baseNameCandidate = `${skuPrefix}${finalTitle}${ratioSuffix}`;
|
||
|
||
if (nameCount[baseNameCandidate]) {
|
||
nameCount[baseNameCandidate]++;
|
||
// Adjust title slightly to accommodate counter?
|
||
// Or just append counter and risk going over limit slightly?
|
||
// Let's shorten title more if needed.
|
||
const countStr = `_${nameCount[baseNameCandidate]}`;
|
||
if ((baseNameCandidate.length + countStr.length + targetExt.length) > 70) {
|
||
// Shrink title more
|
||
const reduceBy = (baseNameCandidate.length + countStr.length + targetExt.length) - 70;
|
||
finalTitle = finalTitle.substring(0, finalTitle.length - reduceBy);
|
||
baseNameCandidate = `${skuPrefix}${finalTitle}${ratioSuffix}`;
|
||
}
|
||
baseNameCandidate += countStr;
|
||
} else {
|
||
nameCount[baseNameCandidate] = 1;
|
||
}
|
||
|
||
const finalFilename = `${baseNameCandidate}${targetExt}`;
|
||
|
||
// ... Processing buffers ...
|
||
|
||
let sourceBuffer: Buffer;
|
||
|
||
// Upscale and enforce exact ratio for master/upscaled types
|
||
if (!isPrintReady && ['master', 'upscaled'].includes(asset.type)) {
|
||
sourceBuffer = await sharp(assetPath)
|
||
.resize(targetWidth, targetHeight, {
|
||
kernel: sharp.kernel.lanczos3,
|
||
fit: 'cover',
|
||
position: 'center'
|
||
})
|
||
.withMetadata({ density: 300 })
|
||
.png({ quality: 100, compressionLevel: 6 })
|
||
.toBuffer();
|
||
} else {
|
||
sourceBuffer = fs.readFileSync(assetPath);
|
||
}
|
||
|
||
if (mode === 'cmyk') {
|
||
// TRUE CMYK Conversion
|
||
let cmykBuffer: Buffer;
|
||
try {
|
||
cmykBuffer = await sharp(sourceBuffer)
|
||
.pipelineColourspace('rgb16')
|
||
.toColourspace('cmyk')
|
||
.withMetadata({ density: 300 })
|
||
.tiff({ compression: 'lzw', quality: 100, xres: 300, yres: 300 })
|
||
.toBuffer();
|
||
} catch (cmykError: any) {
|
||
console.warn(`[CMYK] Fallback for ${finalFilename}`);
|
||
cmykBuffer = await sharp(sourceBuffer)
|
||
.withMetadata({ density: 300 })
|
||
.tiff({ compression: 'lzw', quality: 100, xres: 300, yres: 300 })
|
||
.toBuffer();
|
||
}
|
||
archive.append(cmykBuffer, { name: `print_ready/${finalFilename}` });
|
||
} else {
|
||
archive.append(sourceBuffer, { name: `digital_files_rgb/${finalFilename}` });
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error(`Processing failed for ${assetPath}:`, e);
|
||
// Fallback to original
|
||
archive.file(assetPath, { name: `fallback/${path.basename(assetPath)}` });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add Guide
|
||
if (project.seoData?.printingGuide) {
|
||
const guidePath = path.join(STORAGE_ROOT, project.seoData.printingGuide);
|
||
if (fs.existsSync(guidePath)) {
|
||
archive.file(guidePath, { name: 'Printing_Instructions.txt' });
|
||
}
|
||
}
|
||
|
||
await archive.finalize();
|
||
}
|
||
}
|
||
|
||
export const archiveService = new ArchiveService();
|