@shibam/sticker-maker 1.3.0-rc1 → 1.3.0-rc3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@shibam/sticker-maker",
3
- "version": "1.3.0-rc1",
3
+ "version": "1.3.0-rc3",
4
4
  "description": "A package for creating stickers",
5
- "main": "dist/index.js",
5
+ "main": "src/index.js",
6
6
  "type": "module",
7
7
  "files": [
8
- "dist"
8
+ "src"
9
9
  ],
10
10
  "scripts": {},
11
11
  "author": "Shibam Dey",
@@ -24,7 +24,7 @@
24
24
  "typescript"
25
25
  ],
26
26
  "dependencies": {
27
- "file-type": "^19.4.0",
27
+ "file-type": "^22.0.1",
28
28
  "fs-extra": "^11.3.5",
29
29
  "node-webpmux": "^3.2.0"
30
30
  }
package/src/index.js ADDED
@@ -0,0 +1,57 @@
1
+ import fs from 'fs-extra'
2
+ import Utils from './lib/utils.js'
3
+ import ToWebp from './lib/ToWebp.js'
4
+ import MetaInfoChanger from './lib/changeMetaInfo.js'
5
+
6
+ export default class Sticker {
7
+ constructor(data, metaInfo = {}) {
8
+ this.data = data
9
+ this.metaInfo = { ...metaInfo }
10
+ this.utils = new Utils()
11
+ this.buffer = null
12
+ this.mimeType = ''
13
+ this.extType = ''
14
+ this.initialized = false
15
+ }
16
+
17
+ async initialize() {
18
+ if (this.initialized) return
19
+ this.buffer = await this.utils.buffer(this.data)
20
+ const fileType = await this.utils.getMimeType(this.buffer)
21
+ this.mimeType = fileType?.mime || ''
22
+ this.extType = fileType?.ext || ''
23
+ this.metaInfo.pack ??= ''
24
+ this.metaInfo.author ??= ''
25
+ this.metaInfo.id ??= this.utils.getId()
26
+ this.metaInfo.category ??= []
27
+ this.metaInfo.type ??= 'DEFAULT'
28
+ this.metaInfo.quality ??= this.utils.getQuality(this.buffer)
29
+ this.metaInfo.text ??= ''
30
+ this.initialized = true
31
+ }
32
+
33
+ async toBuffer() {
34
+ await this.initialize()
35
+ const toWebp = await ToWebp(this.buffer, this.metaInfo, this.extType, this.mimeType)
36
+ return new MetaInfoChanger(this.metaInfo).add(toWebp)
37
+ }
38
+
39
+ async toFile(outputPath) {
40
+ await this.initialize()
41
+ const buffer = this.extType === 'webp' ? await this.changeMetaInfo() : await this.toBuffer()
42
+ await fs.writeFile(outputPath, buffer)
43
+ }
44
+
45
+ async changeMetaInfo() {
46
+ await this.initialize()
47
+ return new MetaInfoChanger(this.metaInfo).add(this.buffer)
48
+ }
49
+ }
50
+
51
+ const StickerTypes = {
52
+ DEFAULT: 'DEFAULT',
53
+ CIRCLE: 'CIRCLE',
54
+ SQUARE: 'SQUARE'
55
+ }
56
+ export { default as extractMetaData } from './lib/extractMetaData.js'
57
+ export { StickerTypes }
@@ -0,0 +1,61 @@
1
+ import { promisify } from 'util'
2
+ import { execFile } from 'child_process'
3
+ import fs from 'fs-extra'
4
+ import os from 'os'
5
+ import path from 'path'
6
+ import toGif from './toGif.js'
7
+ import TextOnImg from './textOnImg.js'
8
+
9
+ const exec = promisify(execFile)
10
+ const textOnImg = new TextOnImg()
11
+
12
+ export default async function ToWebp(buffer, metaInfo, mimeExt, mimeType) {
13
+ const timestamp = Date.now()
14
+ const inputPath = path.join(os.tmpdir(), `input_${timestamp}.${mimeExt}`)
15
+ const outputPath = path.join(os.tmpdir(), `output_${timestamp}.webp`)
16
+
17
+ try {
18
+ if (mimeExt === 'webp') return buffer
19
+
20
+ let processedData = buffer
21
+ let currentExt = mimeExt
22
+ const isAnimated = mimeType?.includes('video') || mimeExt === 'gif'
23
+
24
+ if (mimeType?.includes('video')) {
25
+ processedData = await toGif(buffer, mimeExt, metaInfo.type || 'DEFAULT', metaInfo.text ?? '')
26
+ currentExt = 'gif'
27
+ } else if (metaInfo.text) {
28
+ processedData = await textOnImg.drawText(buffer, metaInfo.text)
29
+ currentExt = 'png'
30
+ }
31
+
32
+ await fs.writeFile(inputPath, processedData)
33
+
34
+ let videoFilter = ''
35
+ if (metaInfo.type === 'CIRCLE') {
36
+ videoFilter = 'scale=512:512:force_original_aspect_ratio=increase,crop=512:512,format=rgba,geq=r=\'r(X,Y)\':g=\'g(X,Y)\':b=\'b(X,Y)\':a=\'if(lte((X-256)*(X-256)+(Y-256)*(Y-256),65536),255,0)\''
37
+ } else if (metaInfo.type === 'SQUARE') {
38
+ videoFilter = 'scale=512:512:force_original_aspect_ratio=decrease'
39
+ } else {
40
+ videoFilter = 'scale=512:512:force_original_aspect_ratio=decrease'
41
+ }
42
+
43
+ const ffmpegArgs = ['-y', '-i', inputPath, '-vf', videoFilter]
44
+
45
+ if (isAnimated) {
46
+ ffmpegArgs.push('-loop', '0', '-pix_fmt', 'yuva420p')
47
+ }
48
+
49
+ ffmpegArgs.push(
50
+ '-c:v', 'libwebp',
51
+ '-quality', String(metaInfo.quality || 80),
52
+ '-lossless', currentExt === 'gif' ? '1' : '0',
53
+ outputPath
54
+ )
55
+
56
+ await exec('ffmpeg', ffmpegArgs)
57
+ return await fs.readFile(outputPath)
58
+ } finally {
59
+ await Promise.all([fs.remove(inputPath), fs.remove(outputPath)]).catch(() => {})
60
+ }
61
+ }
@@ -0,0 +1,43 @@
1
+ import Image from 'node-webpmux'
2
+ import { TextEncoder } from 'util'
3
+
4
+ export default class Exif {
5
+
6
+ constructor(options) {
7
+ this.data = {}
8
+ this.data['sticker-pack-id'] = options.id || ''
9
+ this.data['sticker-pack-name'] = options.pack || ''
10
+ this.data['sticker-pack-publisher'] = options.author || ''
11
+ this.data['emojis'] = options.category || ['😹']
12
+ this.exif = null
13
+ }
14
+
15
+ build = () => {
16
+ const data = JSON.stringify(this.data)
17
+ const exif = Buffer.concat([
18
+ Buffer.from([
19
+ 0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00,
20
+ 0x00, 0x16, 0x00, 0x00, 0x00
21
+ ]),
22
+ Buffer.from(data, 'utf-8')
23
+ ])
24
+ exif.writeUIntLE(new TextEncoder().encode(data).length, 14, 4)
25
+ return exif
26
+ }
27
+
28
+ add = async (image) => {
29
+ const exif = this.exif || this.build()
30
+
31
+ // Load the image if it is not already an instance of Image.Image.
32
+ image = image instanceof Image.Image
33
+ ? image
34
+ : await (async () => {
35
+ const img = new Image.Image()
36
+ await img.load(image)
37
+ return img
38
+ })()
39
+
40
+ image.exif = exif
41
+ return await image.save(null)
42
+ }
43
+ }
@@ -0,0 +1,14 @@
1
+ import Image from 'node-webpmux';
2
+ import Utils from './utils.js';
3
+
4
+ const utils = new Utils();
5
+ const extractMetadata = async (image) => {
6
+ const img = new Image.Image();
7
+ const buffer = await utils.buffer(image);
8
+ const fileType = await utils.getMimeType(buffer);
9
+ if (!fileType || fileType.ext !== 'webp') throw new Error('Unsupported file type for metadata extraction');
10
+ await img.load(image);
11
+ const exif = img.exif?.toString('utf-8') ?? '{}';
12
+ return JSON.parse(exif.substring(exif.indexOf('{'), exif.lastIndexOf('}') + 1) ?? '{}');
13
+ };
14
+ export default extractMetadata;
@@ -0,0 +1,98 @@
1
+ import { promisify } from 'util'
2
+ import { execFile } from 'child_process'
3
+ import fs from 'fs-extra'
4
+ import os from 'os'
5
+ import path from 'path'
6
+
7
+ const exec = promisify(execFile)
8
+
9
+ export default class TextOnGif {
10
+ constructor(maxCharsPerLine = 20) {
11
+ this.maxCharsPerLine = maxCharsPerLine
12
+ }
13
+
14
+ wrapText(text, maxChars = 20) {
15
+ const words = text.split(' ')
16
+ const lines = []
17
+ let currentLine = ''
18
+ for (const word of words) {
19
+ const test = currentLine ? `${currentLine} ${word}` : word
20
+ if (test.length <= maxChars) currentLine = test
21
+ else {
22
+ if (currentLine) lines.push(currentLine)
23
+ currentLine = word.length > maxChars ? word.substring(0, maxChars) : word
24
+ }
25
+ }
26
+ if (currentLine) lines.push(currentLine)
27
+ return lines
28
+ }
29
+
30
+ escapeText(text) {
31
+ return text.replace(/'/g, "'\\''").replace(/:/g, '\\:').replace(/,/g, '\\,').replace(/%/g, '\\%')
32
+ }
33
+
34
+ escapeFontPath(fontPath) {
35
+ return fontPath
36
+ .replace(/\\/g, '/')
37
+ .replace(/:/g, '\\:')
38
+ }
39
+
40
+ getFontPath() {
41
+ const fonts = {
42
+ win32: [
43
+ 'C:/Windows/Fonts/arialbd.ttf',
44
+ 'C:/Windows/Fonts/arial.ttf',
45
+ 'C:/Windows/Fonts/segoeui.ttf'
46
+ ],
47
+ darwin: [
48
+ '/Library/Fonts/Arial Bold.ttf',
49
+ '/System/Library/Fonts/Helvetica.ttc',
50
+ '/Library/Fonts/Arial.ttf'
51
+ ],
52
+ linux: [
53
+ '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
54
+ '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
55
+ '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf'
56
+ ]
57
+ }
58
+ const list = fonts[process.platform] || fonts.linux
59
+ for (const f of list) if (fs.existsSync(f)) return f
60
+ return 'arial.ttf'
61
+ }
62
+
63
+ async drawText(gifBuffer, text, padding = { x: 10, y: 10 }) {
64
+ const inputPath = path.join(os.tmpdir(), `input_${Date.now()}.gif`)
65
+ const outputPath = path.join(os.tmpdir(), `output_${Date.now()}.gif`)
66
+ const fontPath = this.escapeFontPath(this.getFontPath())
67
+
68
+ try {
69
+ await fs.writeFile(inputPath, gifBuffer)
70
+ const { stdout } = await exec('ffprobe', [
71
+ '-v', 'error',
72
+ '-select_streams', 'v:0',
73
+ '-show_entries', 'stream=width,height',
74
+ '-of', 'csv=p=0:s=x',
75
+ inputPath
76
+ ])
77
+ const [width, height] = stdout.trim().split('x').map(Number)
78
+ if (!width || !height) throw new Error('Invalid GIF dimensions')
79
+
80
+ const fontSize = Math.floor(height / 10)
81
+ const lineHeight = fontSize * 1.2
82
+ const lines = this.wrapText(text, this.maxCharsPerLine)
83
+
84
+ const filters = lines.map((line, i) => {
85
+ const y = Math.floor(height - padding.y - (lines.length - i) * lineHeight)
86
+ return `drawtext=fontfile='${fontPath}':text='${this.escapeText(line)}':x=(w-text_w)/2:y=${y}:fontsize=${fontSize}:fontcolor=white:borderw=2:bordercolor=black`
87
+ })
88
+
89
+ await exec('ffmpeg', ['-y', '-i', inputPath, '-vf', filters.join(','), '-c:v', 'gif', outputPath])
90
+ return await fs.readFile(outputPath)
91
+ } catch (err) {
92
+ console.error('TextOnGif error:', err.message)
93
+ throw err
94
+ } finally {
95
+ await Promise.all([fs.remove(inputPath), fs.remove(outputPath)]).catch(() => {})
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,98 @@
1
+ import { promisify } from 'util'
2
+ import { execFile } from 'child_process'
3
+ import fs from 'fs-extra'
4
+ import os from 'os'
5
+ import path from 'path'
6
+
7
+ const exec = promisify(execFile)
8
+
9
+ export default class TextOnImg {
10
+ constructor(maxCharsPerLine = 25) {
11
+ this.maxCharsPerLine = maxCharsPerLine
12
+ }
13
+
14
+ wrapText(text, maxChars = 25) {
15
+ const words = text.split(' ')
16
+ const lines = []
17
+ let currentLine = ''
18
+ for (const word of words) {
19
+ const test = currentLine ? `${currentLine} ${word}` : word
20
+ if (test.length <= maxChars) currentLine = test
21
+ else {
22
+ if (currentLine) lines.push(currentLine)
23
+ currentLine = word.length > maxChars ? word.substring(0, maxChars) : word
24
+ }
25
+ }
26
+ if (currentLine) lines.push(currentLine)
27
+ return lines
28
+ }
29
+
30
+ escapeText(text) {
31
+ return text.replace(/'/g, "'\\''").replace(/:/g, '\\:').replace(/,/g, '\\,').replace(/%/g, '\\%')
32
+ }
33
+
34
+ escapeFontPath(fontPath) {
35
+ return fontPath
36
+ .replace(/\\/g, '/')
37
+ .replace(/:/g, '\\:')
38
+ }
39
+
40
+ getFontPath() {
41
+ const fonts = {
42
+ win32: [
43
+ 'C:/Windows/Fonts/arialbd.ttf',
44
+ 'C:/Windows/Fonts/arial.ttf',
45
+ 'C:/Windows/Fonts/segoeui.ttf'
46
+ ],
47
+ darwin: [
48
+ '/Library/Fonts/Arial Bold.ttf',
49
+ '/System/Library/Fonts/Helvetica.ttc',
50
+ '/Library/Fonts/Arial.ttf'
51
+ ],
52
+ linux: [
53
+ '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
54
+ '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
55
+ '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf'
56
+ ]
57
+ }
58
+ const list = fonts[process.platform] || fonts.linux
59
+ for (const f of list) if (fs.existsSync(f)) return f
60
+ return 'arial.ttf'
61
+ }
62
+
63
+ async drawText(imageBuffer, text, padding = { x: 10, y: 10 }) {
64
+ const inputPath = path.join(os.tmpdir(), `input_${Date.now()}.png`)
65
+ const outputPath = path.join(os.tmpdir(), `output_${Date.now()}.png`)
66
+ const fontPath = this.escapeFontPath(this.getFontPath())
67
+
68
+ try {
69
+ await fs.writeFile(inputPath, imageBuffer)
70
+ const { stdout } = await exec('ffprobe', [
71
+ '-v', 'error',
72
+ '-select_streams', 'v:0',
73
+ '-show_entries', 'stream=width,height',
74
+ '-of', 'csv=p=0:s=x',
75
+ inputPath
76
+ ])
77
+ const [width, height] = stdout.trim().split('x').map(Number)
78
+ if (!width || !height) throw new Error('Invalid image dimensions')
79
+
80
+ const fontSize = Math.floor(height / 10)
81
+ const lineHeight = fontSize * 1.2
82
+ const lines = this.wrapText(text, this.maxCharsPerLine)
83
+
84
+ const filters = lines.map((line, i) => {
85
+ const y = Math.floor(height - padding.y - (lines.length - i) * lineHeight)
86
+ return `drawtext=fontfile='${fontPath}':text='${this.escapeText(line)}':x=(w-text_w)/2:y=${y}:fontsize=${fontSize}:fontcolor=white:borderw=2:bordercolor=black`
87
+ })
88
+
89
+ await exec('ffmpeg', ['-y', '-i', inputPath, '-vf', filters.join(','), outputPath])
90
+ return await fs.readFile(outputPath)
91
+ } catch (err) {
92
+ console.error('TextOnImg error:', err.message)
93
+ throw err
94
+ } finally {
95
+ await Promise.all([fs.remove(inputPath), fs.remove(outputPath)]).catch(() => {})
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,32 @@
1
+ import { promisify } from 'util'
2
+ import { execFile } from 'child_process'
3
+ import fs from 'fs-extra'
4
+ import os from 'os'
5
+ import path from 'path'
6
+ import TextOnGif from './textOnGif.js'
7
+
8
+
9
+ const exec = promisify(execFile)
10
+ const textOnGif = new TextOnGif()
11
+
12
+ export default async function videoToGif(buffer, extType, type, text = '') {
13
+ for (let attempt = 0; attempt < 3; attempt++) {
14
+ const ts = Date.now()
15
+ const inputPath = path.join(os.tmpdir(), `in_${ts}_${attempt}.${extType}`)
16
+ const outputPath = path.join(os.tmpdir(), `out_${ts}_${attempt}.gif`)
17
+ try {
18
+ await fs.writeFile(inputPath, buffer)
19
+ const vf = type === 'SQUARE'
20
+ ? 'scale=320:-1:flags=lanczos,fps=10,crop=min(iw\\,ih):min(iw\\,ih)'
21
+ : 'scale=320:-1:flags=lanczos,fps=20'
22
+ await exec('ffmpeg', ['-y', '-i', inputPath, '-vf', vf, '-t', '7', '-loop', '0', '-f', 'gif', outputPath])
23
+ let result = await fs.readFile(outputPath)
24
+ if (text) result = await textOnGif.drawText(result, text)
25
+ return result
26
+ } catch (error) {
27
+ if (attempt === 2) throw error
28
+ } finally {
29
+ await Promise.all([fs.remove(inputPath), fs.remove(outputPath)]).catch(() => {})
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,37 @@
1
+ import { Readable } from 'stream'
2
+ import fs from 'fs-extra'
3
+ export default class Utils {
4
+ async buffer(data) {
5
+ try {
6
+ if (typeof data === 'string') return await fs.readFile(data)
7
+ if (data instanceof Readable) return await this.streamToBuffer(data)
8
+ return Buffer.from(data)
9
+ } catch (error) {
10
+ throw new Error(`Error converting to buffer: ${error}`)
11
+ }
12
+ }
13
+ async streamToBuffer(stream) {
14
+ const chunks = []
15
+ return new Promise((resolve, reject) => {
16
+ stream.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
17
+ stream.on('end', () => resolve(Buffer.concat(chunks)))
18
+ stream.on('error', reject)
19
+ })
20
+ }
21
+ getQuality(data) {
22
+ const bytes = Buffer.from(data).length / 1024
23
+ return bytes > 4096 ? 8 : bytes > 3072 ? 10 : bytes > 2048 ? 12 : 15
24
+ }
25
+ async getMimeType(data) {
26
+ try {
27
+ const { fileTypeFromBuffer } = await import('file-type')
28
+ return await fileTypeFromBuffer(data)
29
+ } catch (error) {
30
+ console.error(`Error getting MIME type: ${error}`)
31
+ return undefined
32
+ }
33
+ }
34
+ getId() {
35
+ return [...Array(5)].map(() => Math.random().toString(36).substring(2, 15)).join('')
36
+ }
37
+ }