added logo, for snippet command which has some other changes as well (gif support, error handling, bugfixes, etc)

This commit is contained in:
yhqe committed 2026-07-13 03:18:07 -05:00
1 parent 623300ea4a
commit 8cd9380166
2 files changed
+154 -29

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+154 -29
View File
@@ -7,6 +7,7 @@ const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { pipeline } = require('stream/promises'); const { pipeline } = require('stream/promises');
const { Readable } = require('stream'); const { Readable } = require('stream');
const logger = require('../../utils/logger');
function isMp3Attachment(attachment) { function isMp3Attachment(attachment) {
const name = (attachment.name || '').toLowerCase(); const name = (attachment.name || '').toLowerCase();
@@ -15,6 +16,26 @@ function isMp3Attachment(attachment) {
return name.endsWith('.mp3') || contentType.startsWith('audio/'); 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) { function isVideoAttachment(attachment) {
const name = (attachment.name || '').toLowerCase(); const name = (attachment.name || '').toLowerCase();
const contentType = (attachment.contentType || '').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) { async function downloadAttachment(attachment, filePath) {
const attempts = 3;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(attachment.url); const response = await fetch(attachment.url);
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
throw new Error(`failed to download ${attachment.name}`); throw new Error(`failed to download ${attachment.name}: HTTP ${response.status}`);
} }
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath)); 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}`);
}
}
} }
async function getDuration(filePath) { async function getDuration(filePath) {
@@ -119,11 +179,32 @@ function buildEmbed(client, message, description, fields = []) {
return embed; 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 = { module.exports = {
name: 'snippet', name: 'snippet',
description: 'mix a random mp3 segment with a random video segment and export a clipped video', description: 'create a snippet with an image/video and an mp3',
aliases: ['mix', 'clip'], usage: 'snippet <seconds> [mp3 attachment] [image or video attachment]',
usage: 'snippet <seconds> [mp3 attachment] [video attachment]',
async execute(client, message, args = []) { async execute(client, message, args = []) {
const requestedLength = Number.parseInt(args[0], 10); const requestedLength = Number.parseInt(args[0], 10);
@@ -134,22 +215,27 @@ module.exports = {
embeds: [buildEmbed( embeds: [buildEmbed(
client, client,
message, 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 attachments = [...message.attachments.values()];
const mp3Attachment = attachments.find(isMp3Attachment); const mp3Attachment = attachments.find(isMp3Attachment);
const imageAttachment = attachments.find(isImageAttachment);
const gifAttachment = attachments.find(isGifAttachment);
const videoAttachment = attachments.find(isVideoAttachment); 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({ return message.reply({
content: null, content: null,
embeds: [buildEmbed( embeds: [buildEmbed(
client, client,
message, 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,45 +245,49 @@ module.exports = {
embeds: [buildEmbed( embeds: [buildEmbed(
client, client,
message, message,
'<a:loading_icon:1366587619890302998> building your clip...', '<a:loading_icon:1366587619890302998> building your snippet...',
)], )],
}); });
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'twitter-snippet-')); const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'twitter-snippet-'));
const mp3Path = path.join(tempDir, mp3Attachment.name || 'input.mp3'); const audioPath = path.join(tempDir, mp3Attachment.name || 'input.mp3');
const videoPath = path.join(tempDir, videoAttachment.name || 'input.mp4'); const visualPath = path.join(tempDir, visualAttachment.name || (imageAttachment ? 'input.png' : gifAttachment ? 'input.gif' : 'input.mp4'));
const outputPath = path.join(tempDir, 'snippet.mp4'); const outputPath = path.join(tempDir, 'snippet.mp4');
try { try {
await Promise.all([ await Promise.all([
downloadAttachment(mp3Attachment, mp3Path), downloadAttachment(mp3Attachment, audioPath),
downloadAttachment(videoAttachment, videoPath), downloadAttachment(visualAttachment, visualPath),
]); ]);
const mp3Duration = await getDuration(mp3Path); const audioDuration = await getDuration(audioPath);
const videoDuration = await getDuration(videoPath); 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'); throw new Error('requested length is longer than one of the attached files');
} }
const clipLength = requestedLength; const clipLength = requestedLength;
const videoStart = videoDuration === clipLength if (isLoopedVisualSource) {
const audioStart = audioDuration === clipLength
? 0 ? 0
: Math.random() * (videoDuration - clipLength); : Math.random() * (audioDuration - clipLength);
const audioStart = mp3Duration === clipLength
? 0
: Math.random() * (mp3Duration - clipLength);
await runProcess('ffmpeg', [ await runProcess('ffmpeg', [
'-y', '-y',
'-ss', videoStart.toFixed(3), ...(isStillImageSource ? ['-loop', '1'] : ['-stream_loop', '-1']),
'-i', videoPath, '-i', visualPath,
'-ss', audioStart.toFixed(3), '-ss', audioStart.toFixed(3),
'-i', mp3Path, '-i', audioPath,
'-loop', '1',
'-i', logoPath,
'-t', clipLength.toFixed(3), '-t', clipLength.toFixed(3),
'-map', '0:v:0', '-filter_complex', buildVideoFilter(),
'-map', '[v]',
'-map', '1:a:0', '-map', '1:a:0',
'-c:v', 'libx264', '-c:v', 'libx264',
'-preset', 'veryfast', '-preset', 'veryfast',
@@ -205,18 +295,47 @@ module.exports = {
'-c:a', 'aac', '-c:a', 'aac',
'-b:a', '192k', '-b:a', '192k',
'-pix_fmt', 'yuv420p', '-pix_fmt', 'yuv420p',
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-movflags', '+faststart', '-movflags', '+faststart',
outputPath, 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( const finalEmbed = buildEmbed(
client, client,
message, message,
'<:check:1481811973447356496> clip finished successfully.', '<:check:1481811973447356496> clip finished successfully.',
[ [
{ name: 'mp3 source', value: `**${mp3Attachment.name || 'unknown'}**`, inline: true }, { name: 'audio source', value: `**${mp3Attachment.name || 'unknown'}**`, inline: true },
{ name: 'video source', value: `**${videoAttachment.name || 'unknown'}**`, inline: true }, { name: 'visual source', value: `**${visualAttachment.name || 'unknown'}**`, inline: true },
{ name: 'clip length', value: `**${clipLength.toFixed(2)}s**`, inline: true }, { name: 'clip length', value: `**${clipLength.toFixed(2)}s**`, inline: true },
], ],
); );
@@ -227,6 +346,10 @@ module.exports = {
files: [{ attachment: outputPath, name: 'snippet.mp4' }], files: [{ attachment: outputPath, name: 'snippet.mp4' }],
}); });
} catch (err) { } catch (err) {
const errorMessage = getErrorMessage(err);
logger.error('[snippet] failed to build clip', err instanceof Error ? err : new Error(String(err)));
await sent.edit({ await sent.edit({
content: null, content: null,
embeds: [buildEmbed( embeds: [buildEmbed(
@@ -234,10 +357,12 @@ module.exports = {
message, message,
'<:no:1481811955592466492> failed to build the clip.', '<: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 { } finally {
await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => null); await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => null);
} }