feat: add MP4 video generation endpoint for LINE stickers
Browse filesAdd new endpoint `/mp4/single/:stickerId` to generate MP4 videos combining sound and animation/static images from LINE stickers. The endpoint accepts parameters for device type (ios/android), static/animation mode, and size variant. Implements ffmpeg processing to merge audio and visual components with proper scaling and format conversion. Includes input validation, error handling, and temporary file cleanup.
src/routes/download/sticker.ts
CHANGED
|
@@ -288,4 +288,81 @@ app.get('/sound/thumb/:productId', async (c) => {
|
|
| 288 |
}
|
| 289 |
});
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
export default app;
|
|
|
|
| 288 |
}
|
| 289 |
});
|
| 290 |
|
| 291 |
+
// SOUND+(ANIMATION or STATIC) εη»εγγ¦γ³γγΌγγ¨γ³γγγ€γ³γ
|
| 292 |
+
app.get('/mp4/single/:stickerId', async (c) => {
|
| 293 |
+
const validDeviceTypes = ['ios', 'android'] as const;
|
| 294 |
+
type DeviceType = (typeof validDeviceTypes)[number];
|
| 295 |
+
const transformedValidDeviceTypes = { ios: 'ios', android: 'android' } as const satisfies Record<DeviceType, string>;
|
| 296 |
+
type DeviceTypeTransformed = (typeof transformedValidDeviceTypes)[DeviceType];
|
| 297 |
+
|
| 298 |
+
const stickerId: number = parseInt(c.req.param('stickerId') || '-1');
|
| 299 |
+
const deviceType: DeviceType = (c.req.query('device_type') || 'ios') as DeviceType;
|
| 300 |
+
const isStaticFlag: boolean = c.req.query('is_static') === 'true' || false;
|
| 301 |
+
const variantSize: number = parseInt(c.req.query('size') || '2');
|
| 302 |
+
|
| 303 |
+
if (stickerId === -1) return c.text('Invalid stickerId', 400);
|
| 304 |
+
if (!validDeviceTypes.includes(deviceType as DeviceType)) return c.text('Invalid device_type', 400);
|
| 305 |
+
if (variantSize > 2 || variantSize < 1) return c.text('Invalid size', 400);
|
| 306 |
+
if (deviceType === 'android' && variantSize === 2) return c.text('Invalid device_type or size', 400);
|
| 307 |
+
|
| 308 |
+
try {
|
| 309 |
+
const imageResponse = await ky(
|
| 310 |
+
`https://stickershop.line-scdn.net/stickershop/v1/sticker/${stickerId}/${deviceType}/sticker${
|
| 311 |
+
isStaticFlag ? '' : '_animation'
|
| 312 |
+
}${variantSize === 2 ? '@2x' : ''}.png`,
|
| 313 |
+
{
|
| 314 |
+
headers: { 'User-Agent': config.userAgent.lineIos },
|
| 315 |
+
timeout: requestTimeout,
|
| 316 |
+
},
|
| 317 |
+
);
|
| 318 |
+
const soundResponse = await ky(
|
| 319 |
+
`https://stickershop.line-scdn.net/stickershop/v1/sticker/${stickerId}/${transformedValidDeviceTypes[deviceType]}/sticker_sound.m4a`,
|
| 320 |
+
{
|
| 321 |
+
headers: { 'User-Agent': config.userAgent.lineIos },
|
| 322 |
+
timeout: requestTimeout,
|
| 323 |
+
},
|
| 324 |
+
);
|
| 325 |
+
|
| 326 |
+
const randomHash = Math.random().toString(36).substring(2);
|
| 327 |
+
const inputImagePath = `tmp_${randomHash}_input.png`;
|
| 328 |
+
const inputSoundPath = `tmp_${randomHash}_input.m4a`;
|
| 329 |
+
const outputPath = `tmp_${randomHash}_output.mp4`;
|
| 330 |
+
|
| 331 |
+
await fs.promises.writeFile(inputImagePath, Buffer.from(await imageResponse.arrayBuffer()));
|
| 332 |
+
await fs.promises.writeFile(inputSoundPath, Buffer.from(await soundResponse.arrayBuffer()));
|
| 333 |
+
|
| 334 |
+
await new Promise<void>((resolve, reject) => {
|
| 335 |
+
exec(
|
| 336 |
+
`ffmpeg -loglevel warning ${
|
| 337 |
+
isStaticFlag ? '-loop 1 ' : ''
|
| 338 |
+
}-i ${inputImagePath} -i ${inputSoundPath} -map 0:v:0 -map 1:a:0 -filter_complex "[0]scale=iw:ih,split[bg][fg];[bg]drawbox=c=black:t=fill[bg2];[bg2][fg]overlay=0:0:format=auto,format=yuv420p" -c:v libx264 -preset medium -pix_fmt yuv420p ${
|
| 339 |
+
isStaticFlag ? '-r 4 ' : ''
|
| 340 |
+
}-c:a copy ${isStaticFlag ? '-shortest ' : ''}-movflags +faststart ${outputPath}`,
|
| 341 |
+
(error) => {
|
| 342 |
+
if (error) reject(error);
|
| 343 |
+
else resolve();
|
| 344 |
+
},
|
| 345 |
+
);
|
| 346 |
+
});
|
| 347 |
+
|
| 348 |
+
const outputBuffer = await fs.promises.readFile(outputPath);
|
| 349 |
+
for (const filePath of [inputImagePath, inputSoundPath, outputPath]) await fs.promises.unlink(filePath);
|
| 350 |
+
|
| 351 |
+
const filteredHeaders = new Headers();
|
| 352 |
+
filteredHeaders.set('Content-Type', 'video/mp4');
|
| 353 |
+
filteredHeaders.set('X-Origin-Date', imageResponse.headers.get('Date')!);
|
| 354 |
+
|
| 355 |
+
return new Response(outputBuffer, {
|
| 356 |
+
status: 200,
|
| 357 |
+
headers: filteredHeaders,
|
| 358 |
+
});
|
| 359 |
+
} catch (error) {
|
| 360 |
+
if ((error as any).response?.status === 404) {
|
| 361 |
+
return c.text('Not Found', 404);
|
| 362 |
+
}
|
| 363 |
+
logger.error('MP4 process failed', error);
|
| 364 |
+
return c.text('Internal Server Error', 500);
|
| 365 |
+
}
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
export default app;
|