Compare commits

..

No commits in common. "main" and "v3.0.0" have entirely different histories.
main ... v3.0.0

5 changed files with 570 additions and 1045 deletions

1432
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "projbotv3" name = "projbotv3"
version = "3.1.0" version = "3.0.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -11,5 +11,6 @@ serenity = {version="0.11.5", features=["builder", "cache", "client", "framework
songbird = {version="0.3.0", features=["driver", "serenity-rustls"]} songbird = {version="0.3.0", features=["driver", "serenity-rustls"]}
rustls = "0.20" rustls = "0.20"
form-data-builder = "1.0" form-data-builder = "1.0"
gifski = "1.12" png = "0.17"
gif = "0.11"
webpki-roots = "0.22" webpki-roots = "0.22"

View file

@ -4,27 +4,16 @@ Projector Bot V3, written in rust this time.
[V2](https://github.com/tudbut/projectorbotv2_full) [V2](https://github.com/tudbut/projectorbotv2_full)
## Quick start ## How to run it
1. Download the executable from https://github.com/tudbut/projbotv3/releases/latest
2. Download ffmpeg using [the official but complicated process](https://ffmpeg.org/), your package manager of choice, or from the releases page of this repo.
3. Download a video and rename it to `vid.mp4`
4. Put all these downloaded files into the same folder(!)
5. Start a CMD or terminal, `cd WHERE_YOU_PUT_THE_FILES` and run the executable using `./projbotv3 TOKEN_HERE`.
## How to build it
First, install this by [installing the rust toolchain](https://rustup.rs) and then running First, install this by [installing the rust toolchain](https://rustup.rs) and then running
`cargo install --git ` followed by the link to this repo. `cargo install --git ` followed by the link to this repo.
Afterwards, you can use it like this (linux) Afterwards, you can use it like this
``` ```
$ # the ytdl command is just an example
$ ytdl -q 18 -o vid.out https://youtu.be/FtutLA63Cp8 # download a video to vid.mp4 $ ytdl -q 18 -o vid.out https://youtu.be/FtutLA63Cp8 # download a video to vid.mp4
$ projbotv3 $(cat bot-token) # assuming there is a file called bot-token containing the bot token $ projbotv3 $(cat bot-token) # assuming there is a file called bot-token containing the bot token
``` ```
(windows)
`projbotv3 BOT_TOKEN_HERE` (sadly, windows does not support putting that in files)
The bot will now convert the video into its preferred format and then connect to discord. The bot will now convert the video into its preferred format and then connect to discord.
@ -41,17 +30,3 @@ ffmpeg -i vid_30fps.mp4 -vf scale=240:180,setsar=1:1 -deadline realtime vid/%0d.
ffmpeg -i vid.mp4 -deadline realtime aud.opus && mv aud.opus aud_encoded ffmpeg -i vid.mp4 -deadline realtime aud.opus && mv aud.opus aud_encoded
``` ```
## Assumptions of this implementation
In order to perfectly sync the audio and the video together, some assumptions must be made
which may not always be correct. I'm noting them down here so that future contributors can more
easily debug timing issues.
(non-exhaustive)
- Time it takes to create a voice channel is a good way to measure ping
- It takes about 5 * ping + amount_of_megabytes * ping for the GIF to arrive and display on
the client
- API ping has minimal fluctuation
- Uploading any GIF takes less than 5 seconds
- A GIF of 5 seconds of a reasonable video at 25fps and 320x240 will always fall below 8MB in size

View file

@ -1,21 +1,13 @@
use std::{ use std::{
env,
fs::{self, File}, fs::{self, File},
io::Write,
path::PathBuf,
process::{self, Stdio}, process::{self, Stdio},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
thread, thread,
time::Duration, time::Duration,
}; };
use gifski::{progress::NoProgress, Repeat, Settings}; use gif::Encoder;
use png::Decoder;
const MB_1: usize = 1024 * 1024;
const WIDTH: u32 = 320;
const HEIGHT: u32 = 240;
const FAST_INTERNET_SIZE: usize = MB_1 * 3;
pub async fn convert() { pub async fn convert() {
println!("encode: encoding video..."); println!("encode: encoding video...");
@ -26,7 +18,25 @@ pub async fn convert() {
"-i", "-i",
"vid.mp4", "vid.mp4",
"-vf", "-vf",
&format!("scale={WIDTH}:{HEIGHT},setsar=1:1,fps=fps=25"), "fps=fps=25",
"-deadline",
"realtime",
"vid_25fps.mp4",
])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.expect("encode: unable to find or run ffmpeg");
command.wait().expect("encode: ffmpeg failed: mp4->mp4");
let mut command = process::Command::new("ffmpeg")
.args([
"-i",
"vid_25fps.mp4",
"-vf",
"scale=240:180,setsar=1:1",
"-deadline",
"realtime",
"vid/%0d.png", "vid/%0d.png",
]) ])
.stdin(Stdio::inherit()) .stdin(Stdio::inherit())
@ -35,8 +45,9 @@ pub async fn convert() {
.spawn() .spawn()
.expect("encode: unable to find or run ffmpeg"); .expect("encode: unable to find or run ffmpeg");
command.wait().expect("encode: ffmpeg failed: mp4->png"); command.wait().expect("encode: ffmpeg failed: mp4->png");
fs::remove_file("vid_25fps.mp4").expect("encode: rm vid_25fps.mp4 failed");
let mut command = process::Command::new("ffmpeg") let mut command = process::Command::new("ffmpeg")
.args(["-i", "vid.mp4", "aud.opus"]) .args(["-i", "vid.mp4", "-deadline", "realtime", "aud.opus"])
.stdin(Stdio::inherit()) .stdin(Stdio::inherit())
.stdout(Stdio::inherit()) .stdout(Stdio::inherit())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
@ -64,45 +75,56 @@ pub async fn convert() {
thread::spawn(move || { thread::spawn(move || {
let mut image = File::create(format!("vid_encoded/{n}")) let mut image = File::create(format!("vid_encoded/{n}"))
.expect("encode: unable to create gif file"); .expect("encode: unable to create gif file");
let mut buf = Vec::with_capacity(3 * 1024 * 1024); let mut encoder = Some(
let (encoder, writer) = gifski::new(Settings { Encoder::new(&mut image, 240, 180, &[]).expect("encode: unable to create gif"),
width: Some(WIDTH), );
height: Some(HEIGHT), // Write the gif control bytes
quality: 100, encoder
fast: false, .as_mut()
repeat: Repeat::Finite(0), .unwrap()
}) .write_extension(gif::ExtensionData::new_control_ext(
.expect("unable to start encoder"); 4,
thread::spawn(move || { gif::DisposalMethod::Any,
writer false,
.write(&mut buf, &mut NoProgress {}) None,
.expect("gif writer failed"); ))
if !env::var("PROJBOTV3_FAST_INTERNET") .expect("encode: unable to write extension data");
.unwrap_or("".to_owned()) encoder
.is_empty() .as_mut()
&& buf.len() < FAST_INTERNET_SIZE .unwrap()
{ .set_repeat(gif::Repeat::Finite(0))
buf.resize(FAST_INTERNET_SIZE, 0); // extend with zeroes to unify length .expect("encode: unable to set repeat");
}
image
.write_all(buf.as_slice())
.expect("unable to write to file");
*running.lock().unwrap() -= 1;
println!("encode: encoded {n}");
});
// Encode frames into gif // Encode frames into gif
println!("encode: encoding {n}..."); println!("encode: encoding {n}...");
for (gi, i) in ((n * (25 * 5))..dir).enumerate() { for i in (n * (25 * 5))..dir {
// n number of previously encoded gifs * 25 frames per second * 5 seconds // n number of previously encoded gifs * 25 frames per second * 5 seconds
{ {
let fi = i + 1; // because ffmpeg starts counting at 1 :p let i = i + 1; // because ffmpeg starts counting at 1 :p
// Decode frame
let decoder = Decoder::new(
File::open(format!("vid/{i}.png"))
.expect(format!("encode: unable to read vid/{i}.png").as_str()),
);
let mut reader = decoder.read_info().expect(
format!("encode: invalid ffmpeg output in vid/{i}.png").as_str(),
);
let mut buf: Vec<u8> = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).expect(
format!("encode: invalid ffmpeg output in vid/{i}.png").as_str(),
);
let bytes = &mut buf[..info.buffer_size()];
// Encode frame
let mut frame = gif::Frame::from_rgb(240, 180, bytes);
// The gif crate is a little weird with extension data, it writes a
// block for each frame, so we have to remind it of what we want again
// for each frame
frame.delay = 4;
// Add to gif
encoder encoder
.add_frame_png_file( .as_mut()
gi, .unwrap()
PathBuf::from(format!("vid/{fi}.png")), .write_frame(&frame)
gi as f64 / 100.0 * 4.0,
)
.expect("encode: unable to encode frame to gif"); .expect("encode: unable to encode frame to gif");
} }
// We don't want to encode something that is supposed to go into the next frame // We don't want to encode something that is supposed to go into the next frame
@ -110,11 +132,15 @@ pub async fn convert() {
break; break;
} }
} }
*running.lock().unwrap() -= 1;
println!("encode: encoded {n}");
}); });
} }
// Always have 6 running, but no more
while *running.lock().unwrap() >= 6 { while *running.lock().unwrap() >= 6 {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} }
tokio::time::sleep(Duration::from_millis(500)).await;
} }
while *running.lock().unwrap() != 0 { while *running.lock().unwrap() != 0 {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;

View file

@ -69,14 +69,14 @@ async fn send_video(message: Message, ctx: Context) {
.expect("discord: unable to send"); .expect("discord: unable to send");
// Spawn task to send audio - This has to be here, because this is also where the timer is // Spawn task to send audio - This has to be here, because this is also where the timer is
// started // started
let sa = unix_millis();
println!("voice: init");
let channel = guild_id
.create_channel(http, |c| c.name("ProjBotV3-Sound").kind(ChannelType::Voice))
.await
.expect("voice: unable to create channel");
let api_time = unix_millis() - sa;
tokio::spawn(async move { tokio::spawn(async move {
let sa = unix_millis();
println!("voice: init");
let channel = guild_id
.create_channel(http, |c| c.name("ProjBotV3-Sound").kind(ChannelType::Voice))
.await
.expect("voice: unable to create channel");
let api_time = unix_millis() - sa;
*c0.lock().await = Some(channel.id); *c0.lock().await = Some(channel.id);
println!("voice: joining"); println!("voice: joining");
let (handler, err) = songbird.join(guild_id, channel.id).await; let (handler, err) = songbird.join(guild_id, channel.id).await;
@ -113,12 +113,8 @@ async fn send_video(message: Message, ctx: Context) {
let mut to_compensate_for = 0; let mut to_compensate_for = 0;
let mut free_time = 0; let mut free_time = 0;
const MB_1: usize = 1024 * 1024;
// Send frames (5 second long gifs) // Send frames (5 second long gifs)
for mut frame in v.by_ref() { for mut frame in v.by_ref() {
let size_mb = frame.bytes.len() as f32 / MB_1 as f32;
let size_compensation_active = frame.bytes.len() > MB_1;
// Upload the frame to the API, but don't finish off the request. // Upload the frame to the API, but don't finish off the request.
println!("vid: caching"); println!("vid: caching");
let token = token.clone(); let token = token.clone();
@ -208,16 +204,8 @@ async fn send_video(message: Message, ctx: Context) {
} }
// Set free_time to display // Set free_time to display
free_time = to_sleep; free_time = to_sleep;
let size_compensation_time = if size_compensation_active { tokio::time::sleep(Duration::from_millis(to_sleep as u64)).await;
(api_time as f32 * size_mb) as u64 sa = unix_millis();
} else {
0
};
tokio::time::sleep(Duration::from_millis(
to_sleep as u64 - size_compensation_time,
))
.await;
sa = unix_millis() + size_compensation_time;
// Now complete the request. This allows each request to take O(1) time // Now complete the request. This allows each request to take O(1) time
println!("vid: completing"); println!("vid: completing");
@ -226,7 +214,6 @@ async fn send_video(message: Message, ctx: Context) {
}) })
.await .await
.unwrap(); .unwrap();
tokio::time::sleep(Duration::from_millis(size_compensation_time)).await;
} }
// The last frame would immediately be deleted if we didn't wait here. // The last frame would immediately be deleted if we didn't wait here.
@ -250,7 +237,7 @@ struct Handler;
#[async_trait] #[async_trait]
impl EventHandler for Handler { impl EventHandler for Handler {
async fn message(&self, ctx: Context, message: Message) { async fn message(&self, ctx: Context, message: Message) {
if message.guild_id.is_none() { if message.guild_id == None {
return; return;
} }