diff --git a/src/api.ts b/src/api.ts index e8edd02..9562f84 100644 --- a/src/api.ts +++ b/src/api.ts @@ -21,6 +21,7 @@ export interface PixelForgeOptions { pwa?: boolean; web?: boolean; seo?: boolean; + transparent?: boolean; // Note: Individual platform options removed - only essential social generation available @@ -41,6 +42,7 @@ export interface PixelForgeResult { social?: string[]; web?: string[]; seo?: string[]; + transparent?: string[]; }; // Meta tags (always generated) @@ -125,6 +127,7 @@ export async function generateAssets(imagePath: string, options: PixelForgeOptio pwa: options.pwa, web: options.web, seo: options.seo, + transparent: options.transparent, format: options.format as any, verbose: options.verbose }; @@ -176,6 +179,10 @@ export async function generateAssets(imagePath: string, options: PixelForgeOptio ); } + if (options.transparent) { + files.transparent = imageFiles.filter(file => file.includes('transparent')); + } + const result: PixelForgeResult = { images: imageFiles, files, @@ -243,5 +250,14 @@ export async function generateWeb(imagePath: string, outputDir: string = './asse }; } +export async function generateTransparent(imagePath: string, outputDir: string = './assets') { + const result = await generateAssets(imagePath, { transparent: true, outputDir }); + return { + files: result.files.transparent || [], + metaTags: result.metaTags, + summary: result.summary + }; +} + // Export the main function as default export default generateAssets; diff --git a/src/cli/commands/generate-all.ts b/src/cli/commands/generate-all.ts index b87ddc7..ea1966a 100644 --- a/src/cli/commands/generate-all.ts +++ b/src/cli/commands/generate-all.ts @@ -58,7 +58,8 @@ export async function generateAll( file.endsWith('.svg') || file.endsWith('.ico') || file.endsWith('.json') || - file.endsWith('.xml') + file.endsWith('.xml') || + file.endsWith('.html') ); // Always generate meta tags for all generated files @@ -70,10 +71,22 @@ export async function generateAll( await metadataGenerator.saveToFile(config.output.path); - // Complete progress tracking (include meta-tags.html in count) - await progressTracker.complete(assetFiles.length + 1); + // Complete progress tracking (event-driven already counted all) + await progressTracker.complete(); + const { current: actualCreated } = progressTracker.getProgress(); - console.log(`โœ… Generated ${assetFiles.length + 1} files in ${config.output.path}`); + // Section summaries + const imageExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.ico']; + const imageCount = assetFiles.filter(f => imageExts.some(ext => f.toLowerCase().endsWith(ext))).length; + const nonImage = assetFiles.filter(f => !imageExts.some(ext => f.toLowerCase().endsWith(ext))); + console.log(`๐Ÿ“‚ All Assets: ${imageCount} files`); + + if (nonImage.includes('manifest.json')) { + console.log('๐Ÿงพ Generated manifest.json'); + } + console.log('๐Ÿ“ Created meta-tags.html'); + + console.log(`\nโœ… Generated ${actualCreated} files in ${config.output.path}`); if (options.verbose) { console.log('\nGenerated files:'); diff --git a/src/cli/commands/generate-orchestrator.ts b/src/cli/commands/generate-orchestrator.ts index 3e444a3..0b90a75 100644 --- a/src/cli/commands/generate-orchestrator.ts +++ b/src/cli/commands/generate-orchestrator.ts @@ -5,6 +5,8 @@ import type { PixelForgeConfig } from '../../core/config-validator'; import { getProgressTracker, resetProgressTracker } from '../utils/progress-tracker'; import { SmartMetadataGenerator } from '../../core/smart-metadata-generator'; import { makeBackgroundTransparent } from '../../core/transparent-background'; +import path from 'path'; +import { promises as fs } from 'fs'; export interface GenerateOptions { all?: boolean; @@ -38,6 +40,19 @@ export async function generateAssets( format: options.format, verbose: options.verbose }); + // Post-process transparency for all generated raster images if requested + if (options.transparent) { + const imageExts = ['.png', '.jpg', '.jpeg', '.webp', '.avif']; + const files = await fs.readdir(config.output.path); + const targets = files + .filter(f => imageExts.some(ext => f.toLowerCase().endsWith(ext))) + .map(f => path.join(config.output.path, f)); + for (const filePath of targets) { + const tmp = `${filePath}.tmp`; + await makeBackgroundTransparent(filePath, tmp); + await fs.rename(tmp, filePath); + } + } return; } @@ -87,12 +102,27 @@ export async function generateAssets( results.push(seoResult); } - // Handle --transparent (single image output with transparent background) + // Handle transparency flag if (options.transparent) { - const outName = 'transparent.png'; - const outPath = `${config.output.path}/${outName}`; - await makeBackgroundTransparent(sourceImage, outPath); - results.push({ name: 'Transparent Background', files: [outName] }); + if (results.length > 0) { + // Apply transparency to all generated raster images in-place + const imageExts = ['.png', '.jpg', '.jpeg', '.webp', '.avif']; + const filesToProcess = Array.from(new Set(results.flatMap(r => r.files))) + .filter(name => imageExts.some(ext => name.toLowerCase().endsWith(ext))) + .map(name => path.join(config.output.path, name)); + + for (const filePath of filesToProcess) { + const tmp = `${filePath}.tmp`; + await makeBackgroundTransparent(filePath, tmp); + await fs.rename(tmp, filePath); + } + } else { + // No other generators selected: create a single transparent image + const outName = 'transparent.png'; + const outPath = path.join(config.output.path, outName); + await makeBackgroundTransparent(sourceImage, outPath); + results.push({ name: 'Transparent Background', files: [outName] }); + } } // If no specific options provided, default to social @@ -111,23 +141,39 @@ export async function generateAssets( await metadataGenerator.saveToFile(config.output.path); - // Complete progress tracking (include meta-tags.html in count) - const totalFiles = results.reduce((sum, { files }) => sum + files.length, 0); - await progressTracker.complete(totalFiles + 1); // +1 for meta-tags.html + // Complete progress tracking (event-driven count already includes all files) + await progressTracker.complete(); // Display summary console.log('โœ… Generation complete!\n'); + const imageExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.ico']; results.forEach(({ name, files }) => { - console.log(`๐Ÿ“‚ ${name}: ${files.length} files`); + const imageFiles = files.filter(f => imageExts.some(ext => f.toLowerCase().endsWith(ext))); + const nonImageFiles = files.filter(f => !imageExts.some(ext => f.toLowerCase().endsWith(ext))); + console.log(`๐Ÿ“‚ ${name}: ${imageFiles.length} files`); if (options.verbose) { - files.slice(0, 5).forEach(file => console.log(` ๐Ÿ“„ ${file}`)); - if (files.length > 5) { - console.log(` ... and ${files.length - 5} more`); + imageFiles.slice(0, 5).forEach(file => console.log(` ๐Ÿ“„ ${file}`)); + if (imageFiles.length > 5) { + console.log(` ... and ${imageFiles.length - 5} more`); } } + // Mention special non-image files in context of the section + if (nonImageFiles.includes('manifest.json')) { + console.log('๐Ÿงพ Generated manifest.json'); + } + if (nonImageFiles.includes('meta-tags.html')) { + console.log('๐Ÿ“ Created meta-tags.html'); + } }); - console.log(`\n๐ŸŽ‰ Total: ${totalFiles + 1} files generated in ${config.output.path}`); + // Mention special non-image files if present + // Also print special files if they weren't associated with a section + const manifestPath = path.join(config.output.path, 'manifest.json'); + try { await fs.access(manifestPath); console.log('๐Ÿงพ Generated manifest.json'); } catch {} + console.log('๐Ÿ“ Created meta-tags.html'); + + const { current: actualCreated } = progressTracker.getProgress(); + console.log(`\n๐ŸŽ‰ Total: ${actualCreated} files generated in ${config.output.path}`); } catch (error) { progressTracker.stop(); throw error; diff --git a/src/cli/utils/progress-tracker.ts b/src/cli/utils/progress-tracker.ts index 315c7bf..e96262f 100644 --- a/src/cli/utils/progress-tracker.ts +++ b/src/cli/utils/progress-tracker.ts @@ -1,5 +1,7 @@ import * as cliProgress from 'cli-progress'; import { promises as fs } from 'fs'; +import path from 'path'; +import { setProgressRecorder } from '../../core/progress-events'; import { GenerateOptions } from '../commands/generate-orchestrator'; export interface ProgressConfig { @@ -19,11 +21,13 @@ export class ProgressTracker { private outputDirectory = ''; private pollingInterval: NodeJS.Timeout | null = null; private isPolling = false; + private usingEvents = false; + private createdFiles: Set = new Set(); // File extensions we consider as generated assets private readonly assetExtensions = [ '.png', '.jpg', '.jpeg', '.webp', '.svg', '.ico', - '.json', '.xml' + '.json', '.xml', '.html' ]; /** @@ -32,28 +36,34 @@ export class ProgressTracker { static estimateFileCount(options: GenerateOptions): number { let totalFiles = 0; - // Favicon generation (essential files with multiple sizes) - if (options.favicon) { - totalFiles += 6; // favicon.ico, favicon-16x16.png, favicon-32x32.png, favicon-48x48.png, apple-touch-icon.png, safari-pinned-tab.svg - } + // If --web flag, it includes favicon + PWA + SEO package + if (options.web) { + totalFiles += 16; // complete web package (favicons + PWA + SEO images) + } else { + // Favicon generation (essential files with multiple sizes) + if (options.favicon) { + totalFiles += 6; // favicon.ico, favicon-16x16.png, favicon-32x32.png, favicon-48x48.png, apple-touch-icon.png, safari-pinned-tab.svg + } - // PWA generation (essential files only) - if (options.pwa) { - totalFiles += 7; // pwa icons (4) + splash screens (2) + manifest.json (1) - } + // PWA generation (essential files only) + if (options.pwa) { + totalFiles += 7; // pwa icons (4) + splash screens (2) + manifest.json (1) + } - // Social media generation (optimized essential files only) - if (options.social) { - totalFiles += 3; // social-media-general.png, instagram-square.png, social-vertical.png - } + // Social media generation (optimized essential files only) + if (options.social) { + totalFiles += 3; // social-media-general.png, instagram-square.png, social-vertical.png + } - // SEO/Web generation - if (options.seo || options.web) { - totalFiles += 3; // og-image.png, opengraph.png, twitter-image.png + // SEO generation + if (options.seo) { + totalFiles += 3; // og-image.png, opengraph.png, twitter-image.png + } } - // Transparent background generation (single output) - if ((options as any).transparent) { + // Transparent background generation (single output when used alone) + const hasOtherGenerators = !!(options.all || options.web || options.favicon || options.pwa || options.seo || options.social); + if ((options as any).transparent && !hasOtherGenerators) { totalFiles += 1; // one transparent image } @@ -132,9 +142,17 @@ export class ProgressTracker { this.totalFiles = ProgressTracker.estimateFileCount(options); this.outputDirectory = outputDirectory; this.currentProgress = 0; + this.createdFiles.clear(); - // Count existing files to use as baseline - this.initialFileCount = await this.countAssetFiles(outputDirectory); + // Snapshot existing assets per extension for accurate diffing + try { + const files = await fs.readdir(outputDirectory); + this.initialFileCount = files.filter(file => + this.assetExtensions.some(ext => file.toLowerCase().endsWith(ext)) + ).length; + } catch (_err) { + this.initialFileCount = 0; + } // Create progress bar with custom format this.bar = new cliProgress.SingleBar({ @@ -148,9 +166,22 @@ export class ProgressTracker { }, cliProgress.Presets.shades_classic); this.bar.start(this.totalFiles, 0); + // Prefer event-driven updates; disable polling to avoid regressions + this.usingEvents = true; + setProgressRecorder((filePath: string) => { + if (!this.bar) return; + const lower = filePath.toLowerCase(); + if (this.assetExtensions.some(ext => lower.endsWith(ext))) { + this.currentProgress = Math.min(this.currentProgress + 1, this.totalFiles); + this.bar.update(this.currentProgress); + this.createdFiles.add(path.basename(filePath)); + } + }); - // Start monitoring the directory for file changes - await this.startPolling(); + // If events aren't available for some reason, fallback to polling + if (!this.usingEvents) { + await this.startPolling(); + } } /** @@ -159,13 +190,15 @@ export class ProgressTracker { async complete(actualFileCount?: number): Promise { // Stop polling first this.stopPolling(); + setProgressRecorder(null); + this.usingEvents = false; if (!this.bar) return; // Get final file count from directory if not provided if (!actualFileCount && this.outputDirectory) { const finalCount = await this.countAssetFiles(this.outputDirectory); - actualFileCount = finalCount - this.initialFileCount; + actualFileCount = Math.max(finalCount - this.initialFileCount, 0); } // If actual count is provided and different from estimate, adjust diff --git a/src/core/image-processor.ts b/src/core/image-processor.ts index ec5a4db..ccd7ed6 100644 --- a/src/core/image-processor.ts +++ b/src/core/image-processor.ts @@ -2,6 +2,7 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { promises as fs } from 'fs'; +import { emitProgress } from './progress-events'; // Minimal Jimp typing to avoid unsafe any while supporting ESM dynamic import type JimpImage = { bitmap: { width: number; height: number; data: Buffer }; @@ -494,6 +495,7 @@ export class ImageProcessor { } await fs.mkdir(path.dirname(outputPath), { recursive: true }); await img.writeAsync(outputPath); + emitProgress(outputPath); return; } @@ -565,6 +567,7 @@ export class ImageProcessor { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`ImageMagick save failed: ${errorMessage}`); } + emitProgress(outputPath); } /** diff --git a/src/core/progress-events.ts b/src/core/progress-events.ts new file mode 100644 index 0000000..520cf21 --- /dev/null +++ b/src/core/progress-events.ts @@ -0,0 +1,19 @@ +// Lightweight global progress event hooks to avoid coupling core to the CLI + +type Recorder = (filePath: string) => void; + +let recorder: Recorder | null = null; + +export function setProgressRecorder(fn: Recorder | null): void { + recorder = fn; +} + +export function emitProgress(filePath: string): void { + try { + recorder?.(filePath); + } catch { + // Do not let UI progress errors break core image writes + } +} + + diff --git a/src/core/smart-metadata-generator.ts b/src/core/smart-metadata-generator.ts index bb0f836..8bc7995 100644 --- a/src/core/smart-metadata-generator.ts +++ b/src/core/smart-metadata-generator.ts @@ -209,5 +209,12 @@ ${metaTags.map(tag => tag.startsWith('