added snippet comband
This commit is contained in:
1 parent
2d29b80b50
commit
623300ea4a
1 file changed
+245
@@ -0,0 +1,245 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = require('fs/promises');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { pipeline } = require('stream/promises');
|
||||||
|
const { Readable } = require('stream');
|
||||||
|
|
||||||
|
function isMp3Attachment(attachment) {
|
||||||
|
const name = (attachment.name || '').toLowerCase();
|
||||||
|
const contentType = (attachment.contentType || '').toLowerCase();
|
||||||
|
|
||||||
|
return name.endsWith('.mp3') || contentType.startsWith('audio/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoAttachment(attachment) {
|
||||||
|
const name = (attachment.name || '').toLowerCase();
|
||||||
|
const contentType = (attachment.contentType || '').toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
name.endsWith('.mp4') ||
|
||||||
|
name.endsWith('.mov') ||
|
||||||
|
name.endsWith('.mkv') ||
|
||||||
|
name.endsWith('.webm') ||
|
||||||
|
name.endsWith('.m4v') ||
|
||||||
|
contentType.startsWith('video/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProcess(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(stderr || `${command} exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAttachment(attachment, filePath) {
|
||||||
|
const response = await fetch(attachment.url);
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`failed to download ${attachment.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDuration(filePath) {
|
||||||
|
const probe = spawn('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
filePath,
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
probe.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
probe.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await new Promise((resolve, reject) => {
|
||||||
|
probe.on('error', reject);
|
||||||
|
probe.on('close', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(stderr || 'failed to read media duration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Number.parseFloat(stdout.trim());
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
throw new Error('invalid media duration');
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(client, message, description, fields = []) {
|
||||||
|
const embed = {
|
||||||
|
color: 0x8aa2b3,
|
||||||
|
title: '',
|
||||||
|
author: {
|
||||||
|
name: message.author.username,
|
||||||
|
icon_url: message.author.displayAvatarURL(),
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
footer: {
|
||||||
|
text: `twitter @ ${message.guild?.name || 'dms'}`,
|
||||||
|
icon_url: client.user.displayAvatarURL(),
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
embed.fields = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <seconds> [mp3 attachment] [video attachment]',
|
||||||
|
|
||||||
|
async execute(client, message, args = []) {
|
||||||
|
const requestedLength = Number.parseInt(args[0], 10);
|
||||||
|
|
||||||
|
if (!Number.isInteger(requestedLength) || requestedLength <= 0) {
|
||||||
|
return message.reply({
|
||||||
|
content: null,
|
||||||
|
embeds: [buildEmbed(
|
||||||
|
client,
|
||||||
|
message,
|
||||||
|
'<:no:1481811955592466492> provide a clip length in seconds, then attach one mp3 and one video file.',
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = [...message.attachments.values()];
|
||||||
|
const mp3Attachment = attachments.find(isMp3Attachment);
|
||||||
|
const videoAttachment = attachments.find(isVideoAttachment);
|
||||||
|
|
||||||
|
if (!mp3Attachment || !videoAttachment) {
|
||||||
|
return message.reply({
|
||||||
|
content: null,
|
||||||
|
embeds: [buildEmbed(
|
||||||
|
client,
|
||||||
|
message,
|
||||||
|
'<:no:1481811955592466492> attach exactly one mp3 file and one video file to use this command.',
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await message.reply({
|
||||||
|
content: null,
|
||||||
|
embeds: [buildEmbed(
|
||||||
|
client,
|
||||||
|
message,
|
||||||
|
'<a:loading_icon:1366587619890302998> building your clip...',
|
||||||
|
)],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 outputPath = path.join(tempDir, 'snippet.mp4');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
downloadAttachment(mp3Attachment, mp3Path),
|
||||||
|
downloadAttachment(videoAttachment, videoPath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mp3Duration = await getDuration(mp3Path);
|
||||||
|
const videoDuration = await getDuration(videoPath);
|
||||||
|
|
||||||
|
if (requestedLength > mp3Duration || requestedLength > videoDuration) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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: 'clip length', value: `**${clipLength.toFixed(2)}s**`, inline: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await sent.edit({
|
||||||
|
content: null,
|
||||||
|
embeds: [finalEmbed],
|
||||||
|
files: [{ attachment: outputPath, name: 'snippet.mp4' }],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await sent.edit({
|
||||||
|
content: null,
|
||||||
|
embeds: [buildEmbed(
|
||||||
|
client,
|
||||||
|
message,
|
||||||
|
'<:no:1481811955592466492> failed to build the clip.',
|
||||||
|
[
|
||||||
|
{ name: 'reason', value: `**${err.message || 'unknown error'}**` },
|
||||||
|
],
|
||||||
|
)],
|
||||||
|
}).catch(() => null);
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in new issue
Block a user