feat: implement pure JavaScript image watermarking with sharp (no Python)
This commit is contained in:
1 parent
5a7b284ff0
commit
03e821e904
3 files changed
+699
-22
No files matched your search
+105
-22
@@ -1,35 +1,118 @@
|
||||
import { spawn } from "child_process";
|
||||
import sharp, { OverlayOptions } from "sharp";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Executes the Python watermark processor on an image file on disk.
|
||||
* Pure JavaScript image watermarking via Sharp (no Python required).
|
||||
* Applies:
|
||||
* 1. School logo without background in top-left corner
|
||||
* 2. Scannable official WeChat QR code in bottom-right corner
|
||||
* 3. Bottom text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
* 1. School logo in top-left corner
|
||||
* 2. WeChat-scannable QR code in bottom-right corner with white backing
|
||||
* 3. Bottom typography banner: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
*/
|
||||
export async function applyWatermarkToFile(filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return resolve();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseDir = process.cwd();
|
||||
const logoPath = path.join(baseDir, "public", "assets", "brand", "shanghai_karate_logo.png");
|
||||
const qrPath = path.join(baseDir, "public", "assets", "brand", "dojo_qr.png");
|
||||
|
||||
const baseImage = sharp(filePath);
|
||||
const metadata = await baseImage.metadata();
|
||||
const bw = metadata.width || 1024;
|
||||
const bh = metadata.height || 1024;
|
||||
|
||||
const margin = Math.round(bw * 0.035);
|
||||
const compositeLayers: OverlayOptions[] = [];
|
||||
|
||||
// 1. School Logo (Top-Left)
|
||||
if (fs.existsSync(logoPath)) {
|
||||
const targetLogoW = Math.round(bw * 0.28);
|
||||
const resizedLogoBuffer = await sharp(logoPath)
|
||||
.resize({ width: targetLogoW })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
compositeLayers.push({
|
||||
input: resizedLogoBuffer,
|
||||
left: margin,
|
||||
top: margin,
|
||||
});
|
||||
}
|
||||
|
||||
const scriptPath = path.join(process.cwd(), "watermark_processor.py");
|
||||
const py = spawn("python", [scriptPath, filePath, filePath]);
|
||||
// 2. Scannable WeChat QR Code (Bottom-Right)
|
||||
if (fs.existsSync(qrPath)) {
|
||||
const targetQrW = Math.round(bw * 0.17);
|
||||
const qrPad = 6;
|
||||
const totalQrW = targetQrW + qrPad * 2;
|
||||
|
||||
py.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.warn(`[Watermark] Python watermark process exited with code ${code}`);
|
||||
resolve(); // Graceful fallback
|
||||
}
|
||||
// Clean white backing with slight rounded corners for maximum readability
|
||||
const whiteBackingSvg = Buffer.from(
|
||||
`<svg width="${totalQrW}" height="${totalQrW}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" rx="6" fill="#ffffff" fill-opacity="0.96"/>
|
||||
</svg>`
|
||||
);
|
||||
|
||||
const resizedQrBuffer = await sharp(qrPath)
|
||||
.resize({ width: targetQrW, height: targetQrW })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const qrWithBg = await sharp(whiteBackingSvg)
|
||||
.composite([{ input: resizedQrBuffer, left: qrPad, top: qrPad }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const qrPosX = bw - totalQrW - margin;
|
||||
const qrPosY = bh - totalQrW - Math.round(margin * 1.5);
|
||||
|
||||
compositeLayers.push({
|
||||
input: qrWithBg,
|
||||
left: qrPosX,
|
||||
top: qrPosY,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Bottom Text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
|
||||
const fontSize = Math.max(14, Math.round(bw * 0.026));
|
||||
const textSvg = Buffer.from(
|
||||
`<svg width="${bw}" height="${Math.round(fontSize * 2.5)}" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: ${fontSize}px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.shadow {
|
||||
fill: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.main {
|
||||
fill: #fff0c8;
|
||||
}
|
||||
</style>
|
||||
<text x="2" y="${fontSize + 2}" class="text shadow">上海健志空手道馆 · Shanghai Karate Club · Est. 2008</text>
|
||||
<text x="0" y="${fontSize}" class="text main">上海健志空手道馆 · Shanghai Karate Club · Est. 2008</text>
|
||||
</svg>`
|
||||
);
|
||||
|
||||
const textPosY = bh - Math.round(margin * 1.2) - fontSize;
|
||||
compositeLayers.push({
|
||||
input: textSvg,
|
||||
left: margin,
|
||||
top: Math.max(0, textPosY),
|
||||
});
|
||||
|
||||
py.on("error", (err) => {
|
||||
console.warn("[Watermark] Failed to spawn watermark process:", err);
|
||||
resolve(); // Graceful fallback
|
||||
});
|
||||
});
|
||||
// Apply all overlays and save to file
|
||||
const outputBuffer = await baseImage
|
||||
.composite(compositeLayers)
|
||||
.jpeg({ quality: 95 })
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(filePath, outputBuffer);
|
||||
console.log(`[Watermark] Pure JS watermark successfully applied to ${filePath}`);
|
||||
} catch (err) {
|
||||
console.warn("[Watermark] Failed to apply watermark in JavaScript:", err);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user