diff options
-rw-r--r-- | Cargo.toml | 8 | ||||
-rw-r--r-- | data/2021-12-03 18-22-11.png | bin | 1860275 -> 0 bytes | |||
-rw-r--r-- | data/test.png | bin | 1567 -> 0 bytes | |||
-rw-r--r-- | index.html | 65 | ||||
-rw-r--r-- | src/history.rs | 51 | ||||
-rw-r--r-- | src/lib.rs | 255 | ||||
-rw-r--r-- | src/ocr.rs | 6 |
7 files changed, 260 insertions, 125 deletions
@@ -2,14 +2,15 @@ name = "queue-go-brrr" version = "0.1.0" authors = ["Melody Horn <melody@boringcactus.com>"] -edition = "2018" +edition = "2021" [lib] crate-type = ["cdylib", "rlib"] [dependencies] base64 = "0.13.0" -gloo = "0.4.0" +chrono = { version = "0.4.19", features = ["wasmbind"] } +gloo = { version = "0.4.0", features = ["futures"] } image = "0.23.14" imageproc = "0.22.0" js-sys = "0.3.55" @@ -30,6 +31,9 @@ web-sys = { version = "0.3.55", features = [ "HtmlCanvasElement", "CanvasRenderingContext2d", "Blob", + "MouseEvent", + "Event", + "CssStyleDeclaration", ] } # The `console_error_panic_hook` crate provides better debugging of panics by diff --git a/data/2021-12-03 18-22-11.png b/data/2021-12-03 18-22-11.png Binary files differdeleted file mode 100644 index 048d954..0000000 --- a/data/2021-12-03 18-22-11.png +++ /dev/null diff --git a/data/test.png b/data/test.png Binary files differdeleted file mode 100644 index 0492aaa..0000000 --- a/data/test.png +++ /dev/null @@ -4,65 +4,44 @@ <meta charset="UTF-8"> <title>queue go brrr</title> <style> - #video { - border: 1px solid black; - box-shadow: 2px 2px 3px black; - height:240px; - } - - #photo { - border: 1px solid black; - box-shadow: 2px 2px 3px black; - height:240px; - } - #canvas { display:none; } - .camera { - width: 340px; - display:inline-block; - } - - .output { - width: 340px; - display:inline-block; + video, img { + max-width: 100%; } - #startbutton { - display:block; - position:relative; - margin-left:auto; - margin-right:auto; - bottom:32px; - background-color: rgba(0, 150, 0, 0.5); - border: 1px solid rgba(255, 255, 255, 0.7); - box-shadow: 0 0 1px 2px rgba(0, 0, 0, 0.2); - font-size: 14px; - font-family: "Lucida Grande", "Arial", sans-serif; - color: rgba(255, 255, 255, 1.0); + #video-preview-container { + position: relative; } - main { - font-size: 16px; - font-family: "Lucida Grande", "Arial", sans-serif; - width: 760px; + #target-marker { + position: absolute; + border: 1px solid red; + pointer-events: none; } </style> </head> <body> +<canvas id="canvas"> +</canvas> <main> - <button id="bootbutton">Start!</button> - <div class="camera"> - <video id="video">Video stream not available.</video> - <button id="startbutton">Take photo</button> - </div> - <canvas id="canvas"> - </canvas> + <h1>queue go brrr<span id="eta"></span></h1> + <h2>For tracking your Login Queue (Extreme) raid progression.</h2> + <p> + Share your FFXIV window, and then this tool will figure out how much longer you probably have to wait. + </p> + <h2><button id="bootbutton">Start!</button></h2> <div class="output"> + <h3>Queue Position Preview (it looks like this says <span id="current-queue-position">something once you hit the button</span>)</h3> <img id="photo" alt="The screen capture will appear in this box."> </div> + <h3>Live Window View (click to move the queue position box if it's misaligned)</h3> + <div id="video-preview-container"> + <video id="video">Video stream not available.</video> + <div id="target-marker"></div> + </div> </main> <!-- Note the usage of `type=module` here as this is an ES6 module --> <script type="module"> diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..5216d10 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,51 @@ +// god is dead and we have killed him and consequently std::time::Instant doesn't work on wasm + +use chrono::prelude::*; +use wasm_bindgen::prelude::*; + +struct Reading { + time: DateTime<Local>, + queue_size: u32, +} + +#[derive(Default)] +pub struct History { + data: Vec<Reading>, +} + +impl History { + pub fn record(&mut self, queue_size: u32) { + self.data.push(Reading { + time: Local::now(), + queue_size, + }); + } + + pub fn completion_time(&self) -> Option<js_sys::Date> { + // TODO make this not suck + let &Reading { + time: first_time, + queue_size: first_size, + .. + } = self.data.first()?; + let &Reading { + time: last_time, + queue_size: last_size, + } = self.data.last()?; + let overall_time_elapsed = last_time - first_time; + if overall_time_elapsed.is_zero() { + return None; + } + let overall_queue_motion = first_size - last_size; + if overall_queue_motion == 0 { + return None; + } + let duration_per_step = overall_time_elapsed / overall_queue_motion as i32; + let remaining_duration = duration_per_step * last_size as i32; + let completion_time = last_time + remaining_duration; + let completion_time_gmt = completion_time.timestamp_millis(); + Some(js_sys::Date::new(&JsValue::from( + completion_time_gmt as f64, + ))) + } +} @@ -1,14 +1,13 @@ use image::{DynamicImage, ImageFormat}; +use std::cell::{Cell, RefCell}; use std::rc::Rc; -use std::sync::atomic::Ordering::Relaxed; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use js_sys::{ArrayBuffer, Uint8Array}; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::*; use web_sys::*; +mod history; mod ocr; // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global @@ -18,14 +17,9 @@ mod ocr; static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; async fn blob_to_image(blob: Blob) -> DynamicImage { - let array_buffer_promise = blob.array_buffer(); - let array_buffer: ArrayBuffer = JsFuture::from(array_buffer_promise) + let image_data = gloo::file::futures::read_as_bytes(&blob.into()) .await - .unwrap() - .dyn_into() .unwrap(); - let image_array = Uint8Array::new(&array_buffer); - let image_data = image_array.to_vec(); image::load_from_memory_with_format(&image_data, ImageFormat::Png).unwrap() } @@ -37,30 +31,58 @@ fn image_to_data_uri(image: &DynamicImage) -> String { result } -fn get_queue_size(image: DynamicImage) -> DynamicImage { - let useful_region = image.crop_imm(911, 514, 70, 20); +const QUEUE_SIZE_WIDTH: u32 = 70; +const QUEUE_SIZE_HEIGHT: u32 = 20; + +fn get_queue_size(image: DynamicImage, corner: &(u32, u32)) -> DynamicImage { + let &(x, y) = corner; + let useful_region = image.crop_imm(x, y, QUEUE_SIZE_WIDTH, QUEUE_SIZE_HEIGHT); useful_region } -#[cfg(test)] -#[test] -fn test_queue_size() { - let image = image::open("data/2021-12-03 18-22-11.png").unwrap(); - let queue_size = get_queue_size(image); - queue_size.save("data/test.png").unwrap(); - let result = ocr::ocr(queue_size); - assert_eq!(result, 4_480); +fn update_target_marker( + target_marker: &HtmlElement, + width: &Rc<Cell<u32>>, + height: &Rc<Cell<u32>>, + video: &HtmlVideoElement, + queue_position: &Rc<Cell<(u32, u32)>>, +) { + let (real_x, real_y) = queue_position.get(); + let real_width = width.get() as f64; + let real_height = height.get() as f64; + let fake_width = video.offset_width() as f64; + let fake_height = video.offset_height() as f64; + let fake_x = real_x as f64 / real_width * fake_width; + let fake_y = real_y as f64 / real_height * fake_height; + + let target_width = QUEUE_SIZE_WIDTH as f64 / real_width * fake_width; + let target_height = QUEUE_SIZE_HEIGHT as f64 / real_height * fake_height; + + let style = target_marker.style(); + style + .set_property("left", &format!("{}px", fake_x)) + .unwrap(); + style.set_property("top", &format!("{}px", fake_y)).unwrap(); + style + .set_property("width", &format!("{}px", target_width)) + .unwrap(); + style + .set_property("height", &format!("{}px", target_height)) + .unwrap(); } async fn do_boot() { - let width = Rc::new(AtomicU32::new(0)); - let height = Rc::new(AtomicU32::new(0)); + let width = Rc::new(Cell::new(0)); + let height = Rc::new(Cell::new(0)); + + let streaming = Rc::new(Cell::new(false)); - let streaming = AtomicBool::new(false); + let queue_position = Rc::new(Cell::new((920u32, 520u32))); - let window = window().unwrap(); - let document = window.document().unwrap(); + let document = gloo::utils::document(); + + let history = Rc::new(RefCell::new(history::History::default())); let video: HtmlVideoElement = document .get_element_by_id("video") @@ -72,10 +94,37 @@ async fn do_boot() { .unwrap() .dyn_into() .unwrap(); - let photo = document.get_element_by_id("photo").unwrap(); - let start_button = document.get_element_by_id("startbutton").unwrap(); + let target_marker: HtmlElement = document + .get_element_by_id("target-marker") + .unwrap() + .dyn_into() + .unwrap(); + + let video_click_listener = { + let video = video.clone(); + let also_video = video.clone(); + let queue_position = queue_position.clone(); + let width = width.clone(); + let height = height.clone(); + let target_marker = target_marker.clone(); + gloo::events::EventListener::new(&video, "click", move |event| { + let video = also_video.clone(); + let mouse_event: &MouseEvent = event.dyn_ref().unwrap(); + let mouse_x = mouse_event.offset_x() as f64; + let mouse_y = mouse_event.offset_y() as f64; + let real_width = width.get() as f64; + let real_height = height.get() as f64; + let fake_width = video.offset_width() as f64; + let fake_height = video.offset_height() as f64; + let real_x = mouse_x / fake_width * real_width - QUEUE_SIZE_WIDTH as f64 / 2.0; + let real_y = mouse_y / fake_height * real_height - QUEUE_SIZE_HEIGHT as f64 / 2.0; + queue_position.replace((real_x.round() as u32, real_y.round() as u32)); + update_target_marker(&target_marker, &width, &height, &video, &queue_position); + }) + }; + video_click_listener.forget(); - let navigator = window.navigator(); + let navigator = gloo::utils::window().navigator(); let media_devices = navigator.media_devices().unwrap(); let constraints = { let mut constraints = DisplayMediaStreamConstraints::new(); @@ -89,65 +138,117 @@ async fn do_boot() { let capture_stream = JsFuture::from(capture_stream_promise).await.unwrap(); let capture_stream: MediaStream = capture_stream.dyn_into().unwrap(); video.set_src_object(Some(&capture_stream)); - let video_listener = { + let video_play_listener = { let width = width.clone(); let height = height.clone(); + let streaming = streaming.clone(); let video = video.clone(); let also_video = video.clone(); let canvas = canvas.clone(); + let queue_position = queue_position.clone(); gloo::events::EventListener::new(&video, "canplay", move |_event| { - let _ = also_video.play().unwrap(); - if !streaming.load(Relaxed) { - width.store(also_video.video_width(), Relaxed); - height.store(also_video.video_height(), Relaxed); - canvas.set_attribute("width", &format!("{}", width.load(Relaxed))); - canvas.set_attribute("height", &format!("{}", height.load(Relaxed))); - streaming.store(true, Relaxed); + let video = also_video.clone(); + let _ = video.play().unwrap(); + if !streaming.get() { + width.set(video.video_width()); + height.set(video.video_height()); + canvas.set_attribute("width", &format!("{}", width.get())); + canvas.set_attribute("height", &format!("{}", height.get())); + streaming.set(true); + update_target_marker(&target_marker, &width, &height, &video, &queue_position); } }) }; - video_listener.forget(); - - let start_button_event_listener = { - let photo = photo.clone(); - gloo::events::EventListener::new(&start_button, "click", move |_event| { - let context = canvas.get_context("2d").unwrap().unwrap(); - let context: CanvasRenderingContext2d = context.dyn_into().unwrap(); - canvas.set_width(width.load(Ordering::Relaxed)); - canvas.set_height(height.load(Ordering::Relaxed)); - context - .draw_image_with_html_video_element_and_dw_and_dh( - &video, - 0.0, - 0.0, - width.load(Relaxed).into(), - height.load(Relaxed).into(), - ) - .unwrap(); - - canvas - .to_blob( - Closure::once_into_js(|data: Blob| { - console::log_1(&JsValue::from("hewwo?")); - // lifetime bullshit here - let window = web_sys::window().unwrap(); - let document = window.document().unwrap(); - let photo = document.get_element_by_id("photo").unwrap(); - spawn_local(async move { - let image = blob_to_image(data).await; - let useful_region = get_queue_size(image); - let data_uri = image_to_data_uri(&useful_region); - photo.set_attribute("src", &data_uri).unwrap(); - console::log_1(&JsValue::from("uhhhh hi")); - }) - }) - .dyn_ref() - .unwrap(), - ) - .unwrap(); - }) - }; - start_button_event_listener.forget(); + video_play_listener.forget(); + + let update_interval = gloo::timers::callback::Interval::new(2_000, move || { + let history = history.clone(); + let queue_position = queue_position.clone(); + if !streaming.get() { + return; + } + let context = canvas.get_context("2d").unwrap().unwrap(); + let context: CanvasRenderingContext2d = context.dyn_into().unwrap(); + canvas.set_width(width.get()); + canvas.set_height(height.get()); + context + .draw_image_with_html_video_element_and_dw_and_dh( + &video, + 0.0, + 0.0, + width.get().into(), + height.get().into(), + ) + .unwrap(); + + canvas + .to_blob( + Closure::once_into_js(move |data: Blob| { + let history = history.clone(); + let queue_position = queue_position.clone(); + // lifetime bullshit here + let document = gloo::utils::document(); + let photo = document.get_element_by_id("photo").unwrap(); + let current_queue_position: HtmlElement = document + .get_element_by_id("current-queue-position") + .unwrap() + .dyn_into() + .unwrap(); + let eta: HtmlElement = document + .get_element_by_id("eta") + .unwrap() + .dyn_into() + .unwrap(); + spawn_local(async move { + let image = blob_to_image(data).await; + let useful_region = get_queue_size(image, &queue_position.get()); + let data_uri = image_to_data_uri(&useful_region); + photo.set_attribute("src", &data_uri).unwrap(); + let queue_size = ocr::ocr(useful_region); + match queue_size { + Some(queue_size) => { + current_queue_position.set_inner_text(&format!("{}", queue_size)); + if let Ok(mut history) = history.try_borrow_mut() { + history.record(queue_size); + let estimated_finish = history.completion_time(); + let eta_text = match estimated_finish { + Some(finish) => { + let empty_array = js_sys::Array::new(); + let options = { + let kv_pair = js_sys::Array::new(); + kv_pair.push(&JsValue::from("timeStyle")); + kv_pair.push(&JsValue::from("short")); + let kv_pairs = js_sys::Array::new(); + kv_pairs.push(&kv_pair); + js_sys::Object::from_entries(&kv_pairs).unwrap() + }; + let format = js_sys::Intl::DateTimeFormat::new( + &empty_array, + &options, + ); + let format = format.format(); + let locale_string = + format.call1(&JsValue::UNDEFINED, &finish).unwrap(); + locale_string.as_string().unwrap() + } + None => format!("pending"), + }; + let label = format!(" - ETA {}", eta_text); + eta.set_inner_text(&label); + } + } + None => { + current_queue_position.set_inner_text("something i can't read"); + } + } + }); + }) + .dyn_ref() + .unwrap(), + ) + .unwrap(); + }); + update_interval.forget(); } #[wasm_bindgen] @@ -22,12 +22,12 @@ fn x_matches(image: &GrayImage, template: &GrayImage) -> Vec<u32> { ); match_values .enumerate_pixels() - .filter(|(_x, _y, pix)| pix.0[0] > 0.9) + .filter(|(_x, _y, pix)| pix.0[0] > 0.95) .map(|(x, _y, _pix)| x) .collect() } -pub fn ocr(image: DynamicImage) -> u32 { +pub fn ocr(image: DynamicImage) -> Option<u32> { let grayscale_image = image::imageops::grayscale(&image); let mut digit_x_positions: Vec<(u8, u32)> = (0..10) .flat_map(|i| { @@ -46,5 +46,5 @@ pub fn ocr(image: DynamicImage) -> u32 { .map(|(i, _x)| format!("{}", i)) .collect(); dbg!(&digits); - digits.parse().unwrap() + digits.parse().ok() } |