added logo, for snippet command which has some other changes as well (gif support, error handling, bugfixes, etc)
This commit is contained in:
1 parent
623300ea4a
commit
8cd9380166
2 files changed
+154
-29
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+154
-29
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user