daydreamer-json commited on
Commit
f015033
Β·
verified Β·
1 Parent(s): af3164a

feat: add MP4 video generation endpoint for LINE stickers

Browse files

Add 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.

Files changed (1) hide show
  1. src/routes/download/sticker.ts +77 -0
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;