build-icon.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. #!/usr/bin/env node
  2. import fs from 'fs';
  3. import os from 'os';
  4. import path from 'path';
  5. import { execFileSync } from 'child_process';
  6. import { fileURLToPath } from 'url';
  7. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  8. const projectRoot = path.resolve(__dirname, '..');
  9. const iconsDir = path.join(projectRoot, 'build', 'icons');
  10. const publicAssetsDir = path.join(projectRoot, 'public', 'assets');
  11. const sourcePngPath = path.join(publicAssetsDir, 'vlcode-lite-source.png');
  12. const svgPath = path.join(iconsDir, 'vlcode-lite.svg');
  13. const icnsPath = path.join(iconsDir, 'vlcode-lite.icns');
  14. const publicSvgPath = path.join(publicAssetsDir, 'vlcode-lite-icon.svg');
  15. const publicPngPath = path.join(publicAssetsDir, 'vlcode-lite-icon.png');
  16. const publicFavicon32Path = path.join(publicAssetsDir, 'vlcode-lite-favicon-32.png');
  17. const publicFavicon16Path = path.join(publicAssetsDir, 'vlcode-lite-favicon-16.png');
  18. const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vlcode-icon-'));
  19. const iconsetDir = path.join(tempDir, 'vlcode-lite.iconset');
  20. const iconSizes = [
  21. { size: 16, scale: 1 },
  22. { size: 16, scale: 2 },
  23. { size: 32, scale: 1 },
  24. { size: 32, scale: 2 },
  25. { size: 128, scale: 1 },
  26. { size: 128, scale: 2 },
  27. { size: 256, scale: 1 },
  28. { size: 256, scale: 2 },
  29. { size: 512, scale: 1 },
  30. { size: 512, scale: 2 },
  31. ];
  32. function run(command, args) {
  33. execFileSync(command, args, { stdio: 'inherit' });
  34. }
  35. function readImageSize(filePath) {
  36. const output = execFileSync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', filePath], { encoding: 'utf8' });
  37. const width = Number(output.match(/pixelWidth:\s*(\d+)/)?.[1] || 0);
  38. const height = Number(output.match(/pixelHeight:\s*(\d+)/)?.[1] || 0);
  39. if (!width || !height) {
  40. throw new Error(`Could not determine image dimensions for ${filePath}`);
  41. }
  42. return { width, height };
  43. }
  44. function buildSvgFromPng(pngPath) {
  45. const canvasSize = 1024;
  46. const panelInset = 44;
  47. const panelSize = canvasSize - panelInset * 2;
  48. const panelRadius = 232;
  49. const pngBase64 = fs.readFileSync(pngPath).toString('base64');
  50. const { width, height } = readImageSize(pngPath);
  51. const maxWidth = 900;
  52. const maxHeight = 672;
  53. const scale = Math.min(maxWidth / width, maxHeight / height);
  54. const drawWidth = Math.round(width * scale);
  55. const drawHeight = Math.round(height * scale);
  56. const x = Math.round((canvasSize - drawWidth) / 2);
  57. const y = Math.round((canvasSize - drawHeight) / 2);
  58. return `<svg width="${canvasSize}" height="${canvasSize}" viewBox="0 0 ${canvasSize} ${canvasSize}" fill="none" xmlns="http://www.w3.org/2000/svg">
  59. <defs>
  60. <linearGradient id="bg" x1="120" y1="72" x2="912" y2="968" gradientUnits="userSpaceOnUse">
  61. <stop stop-color="#081018"/>
  62. <stop offset="1" stop-color="#0C1724"/>
  63. </linearGradient>
  64. <radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(512 288) rotate(90) scale(560 660)">
  65. <stop stop-color="#3C9BFF" stop-opacity="0.32"/>
  66. <stop offset="0.56" stop-color="#39C8D6" stop-opacity="0.14"/>
  67. <stop offset="1" stop-color="#39C8D6" stop-opacity="0"/>
  68. </radialGradient>
  69. <linearGradient id="shine" x1="200" y1="124" x2="704" y2="652" gradientUnits="userSpaceOnUse">
  70. <stop stop-color="#FFFFFF" stop-opacity="0.10"/>
  71. <stop offset="0.42" stop-color="#FFFFFF" stop-opacity="0.02"/>
  72. <stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
  73. </linearGradient>
  74. <filter id="shadow" x="0" y="0" width="${canvasSize}" height="${canvasSize}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
  75. <feDropShadow dx="0" dy="22" stdDeviation="26" flood-color="#03060C" flood-opacity="0.42"/>
  76. </filter>
  77. <filter id="logoMaskFilter" x="0" y="0" width="${canvasSize}" height="${canvasSize}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
  78. <feColorMatrix in="SourceGraphic" type="matrix" values="
  79. 0 0 0 0 0
  80. 0 0 0 0 0
  81. 0 0 0 0 0
  82. 3 3 3 0 -0.35
  83. "/>
  84. </filter>
  85. <clipPath id="panel">
  86. <rect x="${panelInset}" y="${panelInset}" width="${panelSize}" height="${panelSize}" rx="${panelRadius}"/>
  87. </clipPath>
  88. <mask id="logoMask" x="${x}" y="${y}" width="${drawWidth}" height="${drawHeight}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" style="mask-type:alpha">
  89. <image href="data:image/png;base64,${pngBase64}" x="${x}" y="${y}" width="${drawWidth}" height="${drawHeight}" preserveAspectRatio="xMidYMid meet" filter="url(#logoMaskFilter)"/>
  90. </mask>
  91. </defs>
  92. <g clip-path="url(#panel)">
  93. <rect x="${panelInset}" y="${panelInset}" width="${panelSize}" height="${panelSize}" rx="${panelRadius}" fill="url(#bg)"/>
  94. <rect x="${panelInset}" y="${panelInset}" width="${panelSize}" height="${panelSize}" rx="${panelRadius}" fill="url(#glow)"/>
  95. <rect x="${panelInset}" y="${panelInset}" width="${panelSize}" height="${panelSize}" rx="${panelRadius}" fill="url(#shine)"/>
  96. <g filter="url(#shadow)">
  97. <image href="data:image/png;base64,${pngBase64}" x="${x}" y="${y}" width="${drawWidth}" height="${drawHeight}" preserveAspectRatio="xMidYMid meet" mask="url(#logoMask)"/>
  98. </g>
  99. </g>
  100. </svg>
  101. `;
  102. }
  103. function findRenderedPng(outputDir) {
  104. const matches = fs.readdirSync(outputDir)
  105. .filter((entry) => entry.endsWith('.png'))
  106. .map((entry) => path.join(outputDir, entry));
  107. if (matches.length === 0) {
  108. throw new Error('Quick Look did not produce a PNG preview for the SVG icon');
  109. }
  110. return matches[0];
  111. }
  112. try {
  113. fs.mkdirSync(iconsDir, { recursive: true });
  114. fs.mkdirSync(publicAssetsDir, { recursive: true });
  115. if (fs.existsSync(sourcePngPath)) {
  116. fs.writeFileSync(svgPath, buildSvgFromPng(sourcePngPath), 'utf8');
  117. }
  118. if (!fs.existsSync(svgPath)) {
  119. throw new Error(`Missing source SVG: ${svgPath}`);
  120. }
  121. fs.copyFileSync(svgPath, publicSvgPath);
  122. fs.mkdirSync(iconsetDir, { recursive: true });
  123. run('qlmanage', ['-t', '-s', '1024', '-o', tempDir, svgPath]);
  124. const basePng = findRenderedPng(tempDir);
  125. fs.copyFileSync(basePng, publicPngPath);
  126. run('sips', ['-z', '32', '32', basePng, '--out', publicFavicon32Path]);
  127. run('sips', ['-z', '16', '16', basePng, '--out', publicFavicon16Path]);
  128. for (const icon of iconSizes) {
  129. const pixels = icon.size * icon.scale;
  130. const suffix = icon.scale === 2 ? '@2x' : '';
  131. const outputPath = path.join(iconsetDir, `icon_${icon.size}x${icon.size}${suffix}.png`);
  132. run('sips', ['-z', String(pixels), String(pixels), basePng, '--out', outputPath]);
  133. }
  134. fs.rmSync(icnsPath, { force: true });
  135. run('iconutil', ['-c', 'icns', iconsetDir, '-o', icnsPath]);
  136. console.log(`[build-icon] Wrote ${svgPath}`);
  137. console.log(`[build-icon] Wrote ${publicSvgPath}`);
  138. console.log(`[build-icon] Wrote ${publicPngPath}`);
  139. console.log(`[build-icon] Wrote ${publicFavicon32Path}`);
  140. console.log(`[build-icon] Wrote ${publicFavicon16Path}`);
  141. console.log(`[build-icon] Wrote ${icnsPath}`);
  142. } finally {
  143. fs.rmSync(tempDir, { recursive: true, force: true });
  144. }