Skip to content

Instantly share code, notes, and snippets.

@kitlith
Last active November 12, 2022 07:41
Show Gist options
  • Save kitlith/5c6de4e75a1afa8c0430341dd84a8d2f to your computer and use it in GitHub Desktop.
Save kitlith/5c6de4e75a1afa8c0430341dd84a8d2f to your computer and use it in GitHub Desktop.
Code used for generating twitter compatible PNGs based off of a bunch of video segments and an m3u8

This is the collection of code I wrote to upload 4k60 video to twitter.

The order of steps was as follows:

  1. Use ffmpeg to segment and/or re-encode video. The important part is to reduce the segments to under 5MB in size, each.
    • I used this command line: ffmpeg -y -i ..\bbb_sunflower_2160p_60fps_normal.mp4 -codec copy -bsf:v h264_mp4toannexb -map 0 -f segment -segment_time 0.1 -segment_list "bbb_4k.m3u8" -segment_list_type m3u8 "bbb-%d.ts"
    • This resulted in 1269 segments for me to handle.
  2. Use generate_file_packing.py to solve the bin packing problem for the set of segments. I set the bin size to 5MiB - 8KiB, knowing I had a 4KiB image for the cover file.
    • I used the DVD logo as my cover image, and set the second palette color for each one to hsv(pack_index, 255, 255)
  3. Use pack_segments.rs to generate png files packed according to the solution file generated by the previous step.
    • This resulted in 250 png files for me to upload to twitter
  4. Upload to twitter. You can thread 4 images x 25 tweets at once, meaning batches of 100 images if done manually.
  5. Get the URL of every image you just uploaded, and put it in a file.
  6. Use munge_m3u8.rs to account for ordering errors when uploading.
    • You can probably strip this down and avoid having to re-download all the images you just uploaded if you put an index into the cover image, or otherwise made absolutely clear the intended ordering.
  7. Upload the generated m3u8 from the last step somewhere.

Having a local HTTP server to test things against the client I wanted to make sure would work was handy at every step of the way, which meant I could make sure that the step I was working on worked before I moved to the next step.

import ptrpy
import os
import json
items = {}
for f in os.listdir():
if f.endswith(".ts"):
items[f] = os.path.getsize(f)
result = prtpy.pack(algorithm=prtpy.packing.bin_completion, binsize = 5 * 1024 * 1024 - 8 * 1024, items=items)
with open("output.json", "w") as output:
json.dump(result, output)
use m3u8_rs::parse_media_playlist_res;
use std::{fs::{self, File}, io::{BufRead, BufReader}, collections::HashMap, time::Duration};
use sha3::{Digest, Sha3_512};
fn main() {
let mut hashes: HashMap<_, _> = std::fs::read_dir(".")
.unwrap()
.into_iter()
.filter_map(Result::ok)
.filter(|f| f.file_name().to_string_lossy().ends_with(".png"))
.map(|f| {
//println!("{}", f.file_name().to_string_lossy());
let mut hasher = Sha3_512::new();
hasher.update(&fs::read(f.file_name()).unwrap());
(hasher.finalize(), Some(f.file_name().into_string().unwrap()))
}).collect();
let twitter_urls: HashMap<_, _> = BufReader::new(File::open("upload_tracker.txt").unwrap())
.lines()
.filter_map(Result::ok)
.filter(|l| l.starts_with("https"))
.map(|uri| {
let mut hasher = Sha3_512::new();
let data = reqwest::blocking::get(&uri).unwrap().bytes().unwrap();
hasher.update(data.as_ref());
let hash = hasher.finalize();
let encoded_hash = hex::encode(hash.as_slice());
let matching_file = hashes.get_mut(&hash).unwrap_or_else(|| {
std::fs::write("fail", data.as_ref()).unwrap();
panic!("incorrect hash {} at uri: {}", &encoded_hash, &uri);
}).take()
.unwrap_or_else(|| {
std::fs::write("fail", data.as_ref()).unwrap();
panic!("duplicate hash {} at uri: {}", &encoded_hash, &uri);
});
std::thread::sleep(Duration::from_millis(10));
(matching_file, uri)
}).collect();
let mut playlist = parse_media_playlist_res(&fs::read("bbb_4k_pack.m3u8").unwrap()).unwrap();
for segment in playlist.segments.iter_mut() {
segment.uri = twitter_urls[&segment.uri].clone();
}
playlist.write_to(&mut File::create("bbb_4k_twitter.m3u8").unwrap()).unwrap();
}
#![feature(seek_stream_len)]
#![feature(let_chains)]
// NOTE: This uses a patched version of png_pong, so it doesn't attempt to recompress image data when writing!
use std::{fs::{self, File}, io::{Seek, Read, Write, BufWriter}, collections::HashMap, rc::Rc, cell::RefCell, borrow::BorrowMut};
use m3u8_rs::{ByteRange, parse_media_playlist_res};
use pix::el::Pixel;
use png_pong::chunk::{Chunk, ImageEnd, ImageData};
struct AndThen<I, F>(I, Option<F>);
impl<I: Iterator, F: FnOnce()> Iterator for AndThen<I, F> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
let res = self.0.next();
if res.is_none() && let Some(fun) = self.1.take() {
fun();
}
res
}
}
const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
struct WrapWriter<W>(RefCell<W>);
impl<W> WrapWriter<W> {
pub fn new(inner: W) -> Self {
WrapWriter(RefCell::new(inner))
}
}
impl<W: Write> Write for &WrapWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.borrow_mut().write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.0.borrow_mut().flush()
}
}
impl<W: Seek> Seek for &WrapWriter<W> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.0.borrow_mut().seek(pos)
}
}
fn main() {
let target_segments = File::open("output.json").unwrap();
let target_segments: Vec<Vec<String>> = serde_json::from_reader(target_segments).unwrap();
let src_png: Vec<Chunk> = png_pong::Decoder::new(File::open("dvd_logo.png").unwrap())
.unwrap()
.into_chunks()
.map(Result::unwrap)
.filter(|c| matches!(c,
Chunk::ImageHeader(_) | Chunk::ImageData(_) | Chunk::ImageEnd(_) | Chunk::Palette(_)
)).collect();
let mut segment_map: HashMap<String, (String, ByteRange)> = target_segments
.into_iter()
.enumerate()
.flat_map(|(pack_idx, items)| {
let pack_name = format!("pack_{}.png", pack_idx);
// there's certainly a better way to do this but this already almost works so i'm just bodging it.
let mut pack_writer = &WrapWriter::new(BufWriter::new(Box::new(File::create(&pack_name).unwrap())));
pack_writer.write_all(&PNG_SIGNATURE).unwrap();
let mut pack = png_pong::Encoder::new(pack_writer).into_chunk_enc();
for chunk in src_png.iter() {
match chunk {
Chunk::ImageHeader(ihdr) => pack.encode(&mut Chunk::ImageHeader(ihdr.clone())).unwrap(),
Chunk::ImageData(idat) => pack.encode(&mut Chunk::ImageData(ImageData::with_data(idat.data.clone()))).unwrap(),
Chunk::Palette(palette) => {
let mut palette = palette.clone();
palette.palette[1] = pix::hsv::Hsv8::new(pack_idx as u8, 255, 255).convert();
pack.encode(&mut Chunk::Palette(palette)).unwrap();
}
Chunk::ImageEnd(_) => break,
_ => unreachable!()
}
}
let res: Vec<_> = items.into_iter().map(|segment_name| {
// i'm checking the position before we write the chunk header, 4 bytes for size, 4 bytes for name.
let offset = pack_writer.stream_position().unwrap() + 4 + 4;
let segment = fs::read(&segment_name).unwrap();
let length = segment.len();
pack.encode(&mut Chunk::ImageData(ImageData::with_data(segment))).unwrap();
(segment_name.clone(), (pack_name.clone(), ByteRange { offset: Some(offset), length: length as u64 }))
}).collect();
pack.encode(&mut Chunk::ImageEnd(ImageEnd)).unwrap();
res
}).collect();
let mut playlist = parse_media_playlist_res(&fs::read("bbb_4k.m3u8").unwrap()).unwrap();
for segment in playlist.segments.iter_mut() {
let (pack_name, byte_range) = segment_map.remove(&segment.uri).unwrap();
segment.byte_range = Some(byte_range);
segment.uri = pack_name;
}
playlist.write_to(&mut File::create("bbb_4k_pack.m3u8").unwrap()).unwrap();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment