aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml8
-rw-r--r--data/2021-12-03 18-22-11.pngbin1860275 -> 0 bytes
-rw-r--r--data/test.pngbin1567 -> 0 bytes
-rw-r--r--index.html65
-rw-r--r--src/history.rs51
-rw-r--r--src/lib.rs255
-rw-r--r--src/ocr.rs6
7 files changed, 260 insertions, 125 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 767c8b8..73a2795 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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
deleted file mode 100644
index 048d954..0000000
--- a/data/2021-12-03 18-22-11.png
+++ /dev/null
Binary files differ
diff --git a/data/test.png b/data/test.png
deleted file mode 100644
index 0492aaa..0000000
--- a/data/test.png
+++ /dev/null
Binary files differ
diff --git a/index.html b/index.html
index b7c0153..d7d8f95 100644
--- a/index.html
+++ b/index.html
@@ -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,
+ )))
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 8f28199..8c8bcc3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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]
diff --git a/src/ocr.rs b/src/ocr.rs
index bc7e535..cde116f 100644
--- a/src/ocr.rs
+++ b/src/ocr.rs
@@ -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()
}