diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..4f118e5 Binary files /dev/null and b/assets/logo.png differ diff --git a/src/commands/music/snippet.js b/src/commands/music/snippet.js index 1c65027..6b057d7 100644 --- a/src/commands/music/snippet.js +++ b/src/commands/music/snippet.js @@ -7,6 +7,7 @@ const path = require('path'); const { spawn } = require('child_process'); const { pipeline } = require('stream/promises'); const { Readable } = require('stream'); +const logger = require('../../utils/logger'); function isMp3Attachment(attachment) { const name = (attachment.name || '').toLowerCase(); @@ -15,6 +16,26 @@ function isMp3Attachment(attachment) { return name.endsWith('.mp3') || contentType.startsWith('audio/'); } +function isImageAttachment(attachment) { + const name = (attachment.name || '').toLowerCase(); + const contentType = (attachment.contentType || '').toLowerCase(); + + return ( + name.endsWith('.png') || + name.endsWith('.jpg') || + name.endsWith('.jpeg') || + name.endsWith('.webp') || + (contentType.startsWith('image/') && contentType !== 'image/gif') + ); +} + +function isGifAttachment(attachment) { + const name = (attachment.name || '').toLowerCase(); + const contentType = (attachment.contentType || '').toLowerCase(); + + return name.endsWith('.gif') || contentType === 'image/gif'; +} + function isVideoAttachment(attachment) { const name = (attachment.name || '').toLowerCase(); const contentType = (attachment.contentType || '').toLowerCase(); @@ -50,14 +71,53 @@ function runProcess(command, args) { }); } +function isRetryableFetchError(err) { + const code = err?.code || err?.cause?.code; + const name = err?.name; + const message = String(err?.message || ''); + + return ( + code === 'UND_ERR_SOCKET' || + code === 'ECONNRESET' || + code === 'EPIPE' || + code === 'ETIMEDOUT' || + name === 'SocketError' || + name === 'FetchError' || + /other side closed/i.test(message) + ); +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function downloadAttachment(attachment, filePath) { - const response = await fetch(attachment.url); + const attempts = 3; - if (!response.ok || !response.body) { - throw new Error(`failed to download ${attachment.name}`); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetch(attachment.url); + + if (!response.ok || !response.body) { + throw new Error(`failed to download ${attachment.name}: HTTP ${response.status}`); + } + + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath)); + return; + } catch (err) { + await fsp.rm(filePath, { force: true }).catch(() => null); + + if (attempt < attempts && isRetryableFetchError(err)) { + logger.warn( + `[snippet] attachment download failed, retrying (${attempt}/${attempts}) for ${attachment.name || 'unknown'}: ${err.message || err}`, + ); + await sleep(250 * attempt); + continue; + } + + throw new Error(`failed to download ${attachment.name}: ${err.message || err}`); + } } - - await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath)); } async function getDuration(filePath) { @@ -119,11 +179,32 @@ function buildEmbed(client, message, description, fields = []) { return embed; } +function getErrorMessage(err) { + if (err instanceof Error) { + return err.stack || err.message; + } + + if (typeof err === 'string') { + return err; + } + + return 'unknown error'; +} + +const logoPath = path.resolve(__dirname, '../../../assets/logo.png'); + +function buildVideoFilter() { + return [ + '[0:v]scale=trunc(iw/2)*2:trunc(ih/2)*2[base]', + '[2:v]format=rgba,colorchannelmixer=aa=0.5,scale=trunc(iw*0.12/2)*2:-1,rotate=(PI/2)*t:ow=rotw(iw):oh=roth(ih):c=none[logo]', + '[base][logo]overlay=x=W-w-16:y=H-h-16:shortest=1[v]', + ].join(';'); +} + module.exports = { name: 'snippet', - description: 'mix a random mp3 segment with a random video segment and export a clipped video', - aliases: ['mix', 'clip'], - usage: 'snippet [mp3 attachment] [video attachment]', + description: 'create a snippet with an image/video and an mp3', + usage: 'snippet [mp3 attachment] [image or video attachment]', async execute(client, message, args = []) { const requestedLength = Number.parseInt(args[0], 10); @@ -134,22 +215,27 @@ module.exports = { embeds: [buildEmbed( client, message, - '<:no:1481811955592466492> provide a clip length in seconds, then attach one mp3 and one video file.', + '<:no:1481811955592466492> provide a clip length in seconds, then attach one mp3 and either one image or one video file.', )], }); } const attachments = [...message.attachments.values()]; const mp3Attachment = attachments.find(isMp3Attachment); + const imageAttachment = attachments.find(isImageAttachment); + const gifAttachment = attachments.find(isGifAttachment); const videoAttachment = attachments.find(isVideoAttachment); - if (!mp3Attachment || !videoAttachment) { + const visualAttachment = imageAttachment || gifAttachment || videoAttachment; + const hasMultipleVisualTypes = [imageAttachment, gifAttachment, videoAttachment].filter(Boolean).length > 1; + + if (!mp3Attachment || !visualAttachment || hasMultipleVisualTypes) { return message.reply({ content: null, embeds: [buildEmbed( client, message, - '<:no:1481811955592466492> attach exactly one mp3 file and one video file to use this command.', + '<:no:1481811955592466492> attach one mp3 file and either one image, one gif, or one video file, but not more than one visual file.', )], }); } @@ -159,64 +245,97 @@ module.exports = { embeds: [buildEmbed( client, message, - ' building your clip...', + ' building your snippet...', )], }); const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'twitter-snippet-')); - const mp3Path = path.join(tempDir, mp3Attachment.name || 'input.mp3'); - const videoPath = path.join(tempDir, videoAttachment.name || 'input.mp4'); + const audioPath = path.join(tempDir, mp3Attachment.name || 'input.mp3'); + const visualPath = path.join(tempDir, visualAttachment.name || (imageAttachment ? 'input.png' : gifAttachment ? 'input.gif' : 'input.mp4')); const outputPath = path.join(tempDir, 'snippet.mp4'); try { await Promise.all([ - downloadAttachment(mp3Attachment, mp3Path), - downloadAttachment(videoAttachment, videoPath), + downloadAttachment(mp3Attachment, audioPath), + downloadAttachment(visualAttachment, visualPath), ]); - const mp3Duration = await getDuration(mp3Path); - const videoDuration = await getDuration(videoPath); + const audioDuration = await getDuration(audioPath); + const isStillImageSource = Boolean(imageAttachment && imageAttachment === visualAttachment); + const isGifSource = Boolean(gifAttachment && gifAttachment === visualAttachment); + const isLoopedVisualSource = isStillImageSource || isGifSource; + const visualDuration = isLoopedVisualSource ? Infinity : await getDuration(visualPath); - if (requestedLength > mp3Duration || requestedLength > videoDuration) { + if (requestedLength > audioDuration || requestedLength > visualDuration) { throw new Error('requested length is longer than one of the attached files'); } const clipLength = requestedLength; - const videoStart = videoDuration === clipLength - ? 0 - : Math.random() * (videoDuration - clipLength); - const audioStart = mp3Duration === clipLength - ? 0 - : Math.random() * (mp3Duration - clipLength); + if (isLoopedVisualSource) { + const audioStart = audioDuration === clipLength + ? 0 + : Math.random() * (audioDuration - clipLength); - await runProcess('ffmpeg', [ - '-y', - '-ss', videoStart.toFixed(3), - '-i', videoPath, - '-ss', audioStart.toFixed(3), - '-i', mp3Path, - '-t', clipLength.toFixed(3), - '-map', '0:v:0', - '-map', '1:a:0', - '-c:v', 'libx264', - '-preset', 'veryfast', - '-crf', '20', - '-c:a', 'aac', - '-b:a', '192k', - '-pix_fmt', 'yuv420p', - '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', - '-movflags', '+faststart', - outputPath, - ]); + await runProcess('ffmpeg', [ + '-y', + ...(isStillImageSource ? ['-loop', '1'] : ['-stream_loop', '-1']), + '-i', visualPath, + '-ss', audioStart.toFixed(3), + '-i', audioPath, + '-loop', '1', + '-i', logoPath, + '-t', clipLength.toFixed(3), + '-filter_complex', buildVideoFilter(), + '-map', '[v]', + '-map', '1:a:0', + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '20', + '-c:a', 'aac', + '-b:a', '192k', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', + outputPath, + ]); + } else { + const videoStart = visualDuration === clipLength + ? 0 + : Math.random() * (visualDuration - clipLength); + const audioStart = audioDuration === clipLength + ? 0 + : Math.random() * (audioDuration - clipLength); + + await runProcess('ffmpeg', [ + '-y', + '-ss', videoStart.toFixed(3), + '-i', visualPath, + '-ss', audioStart.toFixed(3), + '-i', audioPath, + '-loop', '1', + '-i', logoPath, + '-t', clipLength.toFixed(3), + '-filter_complex', buildVideoFilter(), + '-map', '[v]', + '-map', '1:a:0', + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '20', + '-c:a', 'aac', + '-b:a', '192k', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', + outputPath, + ]); + } const finalEmbed = buildEmbed( client, message, '<:check:1481811973447356496> clip finished successfully.', [ - { name: 'mp3 source', value: `**${mp3Attachment.name || 'unknown'}**`, inline: true }, - { name: 'video source', value: `**${videoAttachment.name || 'unknown'}**`, inline: true }, + { name: 'audio source', value: `**${mp3Attachment.name || 'unknown'}**`, inline: true }, + { name: 'visual source', value: `**${visualAttachment.name || 'unknown'}**`, inline: true }, { name: 'clip length', value: `**${clipLength.toFixed(2)}s**`, inline: true }, ], ); @@ -227,6 +346,10 @@ module.exports = { files: [{ attachment: outputPath, name: 'snippet.mp4' }], }); } catch (err) { + const errorMessage = getErrorMessage(err); + + logger.error('[snippet] failed to build clip', err instanceof Error ? err : new Error(String(err))); + await sent.edit({ content: null, embeds: [buildEmbed( @@ -234,10 +357,12 @@ module.exports = { message, '<:no:1481811955592466492> failed to build the clip.', [ - { name: 'reason', value: `**${err.message || 'unknown error'}**` }, + { name: 'reason', value: `**${errorMessage.slice(0, 1000)}**` }, ], )], - }).catch(() => null); + }).catch((editErr) => { + logger.error('[snippet] failed to edit error reply', editErr instanceof Error ? editErr : new Error(String(editErr))); + }); } finally { await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => null); }