first commit
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/dist
|
||||
/output
|
||||
/tools-build
|
||||
node_modules
|
||||
5
.pnpm-debug.log
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"0 debug pnpm:scope": {
|
||||
"selected": 1
|
||||
}
|
||||
}
|
||||
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:16.17
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock app.js /app/
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev -y
|
||||
|
||||
|
||||
ARG PORT=6000
|
||||
|
||||
ENV PORT $PORT
|
||||
|
||||
EXPOSE $PORT
|
||||
|
||||
COPY package.json yarn.lock app.js /app/
|
||||
COPY util/ /app/util
|
||||
COPY public/ /app/public
|
||||
COPY protos/ /app/protos
|
||||
COPY bin/ /app/bin
|
||||
COPY app/ /app/app
|
||||
|
||||
RUN yarn install
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
30
app.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const path = require("path");
|
||||
const grpc = require("@grpc/grpc-js");
|
||||
const protoLoader = require("@grpc/proto-loader");
|
||||
const promisify = require(path.join(__dirname, "util/grpc-promisify"));
|
||||
global.__pgQuery = require(path.join(__dirname, "util/pg")).pgQuery;
|
||||
|
||||
process.on("uncaughtException", (reason, p) => {
|
||||
console.log("error:", reason, p);
|
||||
// Sentry.captureException({ reason, p });
|
||||
// logger.error({ reason, p }, 'uncaughtException error')
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, p) => {
|
||||
console.log("error:", reason, p);
|
||||
// Sentry.captureException({ reason, p });
|
||||
// logger.error({ reason, p }, 'unhandledRejection error')
|
||||
});
|
||||
|
||||
const Image_PATH = path.join(__dirname, "protos/image.proto");
|
||||
|
||||
const imageDefinition = protoLoader.loadSync(Image_PATH, {
|
||||
defaults: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
imageProto: grpc.loadPackageDefinition(imageDefinition).image,
|
||||
imageImpl: require(path.join(__dirname, "/app/controller")),
|
||||
grpc,
|
||||
promisify,
|
||||
};
|
||||
26
app/controller/image.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { getLogger, imageUtil, qiniuUtil } = require("../../util");
|
||||
const logger = getLogger("image");
|
||||
const path = require("path");
|
||||
|
||||
const Merge = async (call, callback) => {
|
||||
const { name } = call.request;
|
||||
logger.info({ name }, "参数");
|
||||
const buffer = await imageUtil.mergeImage(name);
|
||||
console.log(11111, buffer);
|
||||
// const file = await qiniuUtil.uploadFileByBuffer(buffer)
|
||||
callback(null, { path: "success" });
|
||||
};
|
||||
|
||||
const CreateAvatarOrnament = async (call, callback) => {
|
||||
const { avatarPath, ornamentPath } = call.request;
|
||||
logger.info({ avatarPath, ornamentPath }, "参数");
|
||||
const buffer = await imageUtil.avatarOrnament(avatarPath, ornamentPath);
|
||||
// console.log(11111, buffer);
|
||||
// const file = await qiniuUtil.uploadFileByBuffer(buffer)
|
||||
callback(null, { path: "success" });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
Merge,
|
||||
CreateAvatarOrnament,
|
||||
};
|
||||
4
app/controller/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
Merge: require("./image").Merge,
|
||||
CreateAvatarOrnament: require("./image").CreateAvatarOrnament,
|
||||
};
|
||||
42
bin/rpc.js
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
if (process.env.NODE_ENV !== "production") require("dotenv").config();
|
||||
const app = require("../app");
|
||||
const server = new app.grpc.Server();
|
||||
|
||||
const client = new app.imageProto.Image(
|
||||
`${process.env.PORT || 10010}`,
|
||||
app.grpc.credentials.createSsl()
|
||||
);
|
||||
|
||||
app.promisify(client);
|
||||
|
||||
try {
|
||||
let address = `0.0.0.0:${process.env.PORT || 10010}`;
|
||||
server.addService(app.imageProto.Image.service, app.imageImpl);
|
||||
server.bindAsync(
|
||||
address,
|
||||
app.grpc.ServerCredentials.createInsecure(),
|
||||
(err, port) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🚀 gRPC listening on ${port}`);
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("SIGTERM received, shutting down gRPC server...");
|
||||
server.tryShutdown(() => {
|
||||
console.log("gRPC server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// server.start()
|
||||
// console.info(`Image gRPC server: Listening on ${address}`)
|
||||
} catch (error) {
|
||||
console.error(`Image gRPC server error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "grpc_picture",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"repository": "git@github.com:MrZhangZc/grpc_picture.git",
|
||||
"author": "zzc <1761997216@qq.com>",
|
||||
"license": "MIT",
|
||||
"standard": {
|
||||
"globals": [
|
||||
"__pgQuery"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./bin/rpc",
|
||||
"start:dev": "nodemon ./bin/rpc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.7.0",
|
||||
"@grpc/proto-loader": "^0.7.2",
|
||||
"canvas": "^2.10.1",
|
||||
"dotenv": "^16.0.2",
|
||||
"pg": "^8.8.0",
|
||||
"pg-pool": "^3.5.2",
|
||||
"pino": "^8.6.0",
|
||||
"pino-pretty": "^9.1.0",
|
||||
"qiniu": "^7.7.0"
|
||||
}
|
||||
}
|
||||
1362
pnpm-lock.yaml
generated
Normal file
20
protos/image.proto
Normal file
@@ -0,0 +1,20 @@
|
||||
syntax = "proto3";
|
||||
package image;
|
||||
|
||||
service Image {
|
||||
rpc Merge (MergeReq) returns (Reply) {};
|
||||
rpc CreateAvatarOrnament (CreateAvatarOrnamentReq) returns (Reply) {};
|
||||
}
|
||||
|
||||
message MergeReq {
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message CreateAvatarOrnamentReq {
|
||||
string avatar_path = 1;
|
||||
string ornament_path = 2;
|
||||
}
|
||||
|
||||
message Reply {
|
||||
string path = 1;
|
||||
}
|
||||
BIN
public/.DS_Store
vendored
Normal file
BIN
public/font/PINGFANG MEDIUM.TTF
Normal file
BIN
public/merge/base.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/merge/base_card1.jpg
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
util/66.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
38
util/file.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const fs = require('fs')
|
||||
|
||||
const getFileBuffer = (path) => fs.readFileSync(path)
|
||||
|
||||
const getFilePath = (prefixStr = '', postfixStr = '') => {
|
||||
// 文件名称
|
||||
const uuid = () => {
|
||||
function s4 () {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1)
|
||||
}
|
||||
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4()
|
||||
}
|
||||
// 文件扩展名 toLowerCase 转小写
|
||||
let prefix = prefixStr
|
||||
if (prefixStr && prefix.substr(prefix.length - 1, 1) !== '/') { prefix += '/' }
|
||||
// 后缀
|
||||
let postfix = ''
|
||||
if (postfixStr) { postfix = '_' + postfixStr }
|
||||
return prefix + uuid() + postfix + '.jpg'
|
||||
}
|
||||
|
||||
const uuid = () => {
|
||||
function s4 () {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1)
|
||||
}
|
||||
return s4() + s4() + s4()
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
getFileBuffer,
|
||||
getFilePath,
|
||||
uuid
|
||||
}
|
||||
27
util/grpc-promisify.js
Normal file
@@ -0,0 +1,27 @@
|
||||
function promisify (client) {
|
||||
Object.keys(Object.getPrototypeOf(client)).forEach(functionName => {
|
||||
if (functionName.startsWith('$')) {
|
||||
return
|
||||
}
|
||||
const originalFunction = client[functionName]
|
||||
client[functionName] = (request, callback) => {
|
||||
if (callback && typeof callback === 'function') {
|
||||
return originalFunction.call(client, request, (error, response) => {
|
||||
callback(error, response)
|
||||
})
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
originalFunction.call(client, request, (error, response) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(response)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
client[functionName].interceptors = originalFunction.interceptors
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = promisify
|
||||
120
util/image.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const { createCanvas, loadImage, registerFont } = require("canvas");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
registerFont(path.join(process.cwd(), `/public/font/PINGFANG MEDIUM.TTF`), {
|
||||
family: "pf",
|
||||
});
|
||||
|
||||
const mergeImage = async (name) => {
|
||||
const WIDTH = 1080;
|
||||
const HEIGHT = 1920;
|
||||
const canvas = createCanvas(WIDTH, HEIGHT);
|
||||
const context = canvas.getContext("2d");
|
||||
const bg = await loadImage(
|
||||
path.join(process.cwd(), "/public/merge/base_card1.jpg")
|
||||
);
|
||||
context.drawImage(bg, 0, 0, 1080, 1920);
|
||||
context.font = "48px px";
|
||||
context.fillStyle = "#282828";
|
||||
context.textAlign = "center"; //文字水平居中
|
||||
context.fillText(name, 566 / 2, 422);
|
||||
|
||||
context.font = `400 80px pf`;
|
||||
context.textAlign = "center";
|
||||
context.fillStyle = "red";
|
||||
context.fillText(`龙马精神`, WIDTH / 2, 366 + 4);
|
||||
// return canvas.toBuffer();
|
||||
const out = fs.createWriteStream(__dirname + "/test2.png");
|
||||
const stream = canvas.createPNGStream();
|
||||
stream.pipe(out);
|
||||
out.on("finish", () => console.log("The PNG file was created."));
|
||||
};
|
||||
|
||||
// const avatarOrnament = async (avatarPath, ornamentPath, size = 512) => {
|
||||
// // 1️⃣ 加载图片
|
||||
// const avatarImg = await loadImage(avatarPath);
|
||||
// const ornamentImg = await loadImage(ornamentPath);
|
||||
|
||||
// const width = avatarImg.width;
|
||||
// const height = avatarImg.height;
|
||||
|
||||
// const canvas = createCanvas(width, height);
|
||||
// const ctx = canvas.getContext("2d");
|
||||
|
||||
// // 3️⃣ 先画头像
|
||||
// ctx.drawImage(avatarImg, 0, 0, width, height);
|
||||
|
||||
// // 4️⃣ 计算挂饰尺寸(取短边的 25%)
|
||||
// const baseSize = Math.min(width, height);
|
||||
// const ornamentSize = Math.floor(baseSize * 0.25);
|
||||
|
||||
// const ornamentRatio = ornamentImg.width / ornamentImg.height;
|
||||
// const ornamentWidth =
|
||||
// ornamentRatio >= 1 ? ornamentSize : ornamentSize * ornamentRatio;
|
||||
|
||||
// const ornamentHeight =
|
||||
// ornamentRatio >= 1 ? ornamentSize / ornamentRatio : ornamentSize;
|
||||
|
||||
// // 5️⃣ 位置:右下角 + 内边距
|
||||
// const padding = Math.floor(baseSize * 0.05);
|
||||
|
||||
// const x = width - ornamentWidth - padding;
|
||||
// const y = height - ornamentHeight - padding;
|
||||
|
||||
// // 6️⃣ 阴影(非常关键:让合成“自然”)
|
||||
// ctx.save();
|
||||
// ctx.shadowColor = "rgba(0, 0, 0, 0.25)";
|
||||
// ctx.shadowBlur = Math.floor(baseSize * 0.03);
|
||||
// ctx.shadowOffsetX = Math.floor(baseSize * 0.01);
|
||||
// ctx.shadowOffsetY = Math.floor(baseSize * 0.01);
|
||||
|
||||
// ctx.drawImage(ornamentImg, x, y, ornamentWidth, ornamentHeight);
|
||||
// ctx.restore();
|
||||
|
||||
// // 6. 输出文件
|
||||
// // const buffer = canvas.toBuffer("image/png");
|
||||
// // fs.writeFileSync(outputPath, buffer);
|
||||
// const out = fs.createWriteStream(__dirname + "/test3.png");
|
||||
// const stream = canvas.createPNGStream();
|
||||
// stream.pipe(out);
|
||||
// out.on("finish", () => console.log("The PNG file was created."));
|
||||
|
||||
// return "success";
|
||||
// };
|
||||
|
||||
const avatarOrnament = async (avatarPath, ornamentPath, size = 512) => {
|
||||
// 1. 读取头像
|
||||
const avatar = await loadImage(avatarPath);
|
||||
|
||||
// 2. Canvas 尺寸 = 头像尺寸(不裁剪)
|
||||
const width = 500;
|
||||
const height = 500;
|
||||
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// 3. 绘制头像
|
||||
ctx.drawImage(avatar, 0, 0, width, height);
|
||||
|
||||
// 4. 读取装饰外框(PNG,透明)
|
||||
// const frame = await loadImage(ornamentPath);
|
||||
const framePath = path.resolve(__dirname + "/66.png");
|
||||
const frame = await loadImage(framePath);
|
||||
// 5. 覆盖绘制外框
|
||||
ctx.drawImage(frame, 0, 0, width, height);
|
||||
// 6. 输出文件
|
||||
// const buffer = canvas.toBuffer("image/png");
|
||||
// fs.writeFileSync(outputPath, buffer);
|
||||
const out = fs.createWriteStream(__dirname + "/test6.png");
|
||||
const stream = canvas.createPNGStream();
|
||||
stream.pipe(out);
|
||||
out.on("finish", () => console.log("The PNG file was created."));
|
||||
|
||||
return "success";
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
mergeImage,
|
||||
avatarOrnament,
|
||||
};
|
||||
12
util/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const promisify = require('./grpc-promisify')
|
||||
const imageUtil = require('./image')
|
||||
// const fileUtil = require('./file')
|
||||
const qiniuUtil = require('./qiniu')
|
||||
const getLogger = require('./logger')
|
||||
|
||||
module.exports = {
|
||||
imageUtil,
|
||||
promisify,
|
||||
qiniuUtil,
|
||||
getLogger
|
||||
}
|
||||
25
util/logger.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const pino = require('pino')
|
||||
let log
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
log = pino()
|
||||
} else if (process.env.NODE_ENV === 'development') {
|
||||
const transport = pino.transport({
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true }
|
||||
})
|
||||
|
||||
log = pino({ level: 'trace'}, transport)
|
||||
} else {
|
||||
const transport = pino.transport({
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true }
|
||||
})
|
||||
log = pino({level: 'debug'},transport)
|
||||
}
|
||||
|
||||
|
||||
module.exports = getLogger = name => {
|
||||
const logger = log.child({ name })
|
||||
return logger
|
||||
}
|
||||
26
util/pg.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const Pool = require('pg-pool');
|
||||
|
||||
const pgConnection = new Pool({
|
||||
user: process.env.POSTGRESQL_USERNAME,
|
||||
password: process.env.POSTGRESQL_PASSWORD,
|
||||
host: process.env.POSTGRESQL_HOST,
|
||||
port: 5432,
|
||||
database: process.env.POSTGRESQL_DATABASE
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
pgQuery: async (sql, values) => {
|
||||
let connection = false
|
||||
try {
|
||||
connection = await pgConnection.connect()
|
||||
let results = await connection.query(sql, values)
|
||||
return results
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.release()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
135
util/qiniu.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const qiniu = require('qiniu');
|
||||
const { unlinkSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
|
||||
const getFileKey = (file, prefix) => {
|
||||
const uuid = () => {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1);
|
||||
}
|
||||
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4();
|
||||
};
|
||||
const splitted = file ? file.originalname.split('.') : '';
|
||||
const extension = file ? splitted[splitted.length - 1] : 'png';
|
||||
return {
|
||||
fileName: uuid() + '.' + extension,
|
||||
key: prefix
|
||||
? `${prefix}/` + uuid() + '.' + extension
|
||||
: 'resource/' + uuid() + '.' + extension,
|
||||
};
|
||||
};
|
||||
|
||||
const getFileName = (file, prefix) => {
|
||||
const uuid = () => {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1);
|
||||
}
|
||||
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4();
|
||||
};
|
||||
return uuid() + '.png'
|
||||
};
|
||||
|
||||
const deleteFile = (name) => {
|
||||
const path = resolve(__dirname, `../public/${name}`);
|
||||
unlinkSync(path);
|
||||
};
|
||||
|
||||
const saveToQiNIu = (fileName) => {
|
||||
const mac = new qiniu.auth.digest.Mac(
|
||||
process.env.ACCESS_KEY,
|
||||
process.env.SECRET_KEY,
|
||||
);
|
||||
const options = { scope: process.env.BUCKET };
|
||||
const putPolicy = new qiniu.rs.PutPolicy(options);
|
||||
const uploadToken = putPolicy.uploadToken(mac);
|
||||
const config = new qiniu.conf.Config();
|
||||
config.zone = qiniu.zone.Zone_z2;
|
||||
const localFile = resolve(__dirname, `../public/${fileName}`);
|
||||
const putExtra = new qiniu.form_up.PutExtra();
|
||||
const formUploader = new qiniu.form_up.FormUploader(config);
|
||||
return new Promise((resolve, reject) => {
|
||||
formUploader.putFile(
|
||||
uploadToken,
|
||||
fileName,
|
||||
localFile,
|
||||
putExtra,
|
||||
function (respErr, respBody, respInfo) {
|
||||
if (respErr) {
|
||||
reject(respErr);
|
||||
}
|
||||
if (respInfo.statusCode == 200) {
|
||||
resolve(respBody);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteQiNiuSource = (key) => {
|
||||
const mac = new qiniu.auth.digest.Mac(
|
||||
process.env.ACCESS_KEY,
|
||||
process.env.SECRET_KEY,
|
||||
);
|
||||
const config = new qiniu.conf.Config();
|
||||
config.zone = qiniu.zone.Zone_z2;
|
||||
const bucketManager = new qiniu.rs.BucketManager(mac, config);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
bucketManager.delete(
|
||||
process.env.BUCKET,
|
||||
key,
|
||||
function (err, respBody, respInfo) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(respInfo);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFileByBuffer = (buffer, prefix) => {
|
||||
const { key } = getFileKey('', prefix);
|
||||
|
||||
const mac = new qiniu.auth.digest.Mac(
|
||||
process.env.ACCESS_KEY,
|
||||
process.env.SECRET_KEY,
|
||||
);
|
||||
const options = { scope: process.env.BUCKET };
|
||||
const putPolicy = new qiniu.rs.PutPolicy(options);
|
||||
const uploadToken = putPolicy.uploadToken(mac);
|
||||
const config = new qiniu.conf.Config();
|
||||
config.zone = qiniu.zone.Zone_z2;
|
||||
const putExtra = new qiniu.form_up.PutExtra();
|
||||
const formUploader = new qiniu.form_up.FormUploader(config);
|
||||
return new Promise((resolve, reject) => {
|
||||
formUploader.put(
|
||||
uploadToken,
|
||||
key,
|
||||
buffer,
|
||||
putExtra,
|
||||
function (respErr, respBody, respInfo) {
|
||||
if (respErr) {
|
||||
reject(respErr);
|
||||
}
|
||||
if (respInfo.statusCode == 200) {
|
||||
resolve(key);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
saveToQiNIu,
|
||||
deleteQiNiuSource,
|
||||
getFileName,
|
||||
deleteFile,
|
||||
uploadFileByBuffer
|
||||
}
|
||||
BIN
util/test.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
util/test2.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
util/test3.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
util/test5.png
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
util/test6.png
Normal file
|
After Width: | Height: | Size: 327 KiB |