use std::cell::RefCell;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d};
use super::{Encoder, Unit};
use super::{Host, Job, Raster, Request, TextRun};
use crate::Encoding;
use crate::{Error, Pending};
#[derive(Clone, Copy, Debug, Default)]
pub struct Browser;
thread_local! {
static CANVAS: RefCell<Option<(OffscreenCanvas, OffscreenCanvasRenderingContext2d)>> = const { RefCell::new(None) };
static JOBS: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
static ENCODERS: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
}
impl Browser {
pub fn set_jobs(run: js_sys::Function) {
JOBS.with(|jobs| *jobs.borrow_mut() = Some(run));
}
pub fn set_encoder(create: js_sys::Function) {
ENCODERS.with(|encoders| *encoders.borrow_mut() = Some(create));
}
}
struct Script(JsValue);
impl Script {
fn call(&self, name: &str, args: &js_sys::Array) -> Pending<Vec<Unit>> {
let promise = js_sys::Reflect::get(&self.0, &name.into())
.and_then(JsCast::dyn_into::<js_sys::Function>)
.and_then(|f| f.apply(&self.0, args))
.map(|answer| js_sys::Promise::resolve(&answer));
let promise = match promise {
Ok(promise) => promise,
Err(error) => return Pending::ready(Err(Error::Unsupported(format!("the encoder: {error:?}").into()))),
};
let (pending, resolver) = Pending::new();
wasm_bindgen_futures::spawn_local(async move {
let answer = wasm_bindgen_futures::JsFuture::from(promise).await;
resolver.resolve(
answer
.map_err(|error| Error::Unsupported(format!("the encoder failed: {error:?}").into()))
.and_then(units),
);
});
pending
}
}
fn units(answer: JsValue) -> crate::Result<Vec<Unit>> {
let units: js_sys::Array =
answer.dyn_into().map_err(|_| Error::Unsupported("the encoder answered no units".into()))?;
units
.iter()
.map(|unit| {
let unit: js_sys::Array =
unit.dyn_into().map_err(|_| Error::Unsupported("a unit is [time, key, bytes]".into()))?;
let time = unit.get(0).as_f64().ok_or_else(|| Error::Unsupported("a unit's time".into()))?;
let bytes = js_sys::Uint8Array::new(&unit.get(2));
Ok(Unit { time: time as u64, key: unit.get(1).is_truthy(), annex_b: bytes.to_vec() })
})
.collect()
}
impl Drop for Script {
fn drop(&mut self) {
if let Ok(close) = js_sys::Reflect::get(&self.0, &"close".into()).and_then(JsCast::dyn_into::<js_sys::Function>)
{
let _ = close.call0(&self.0);
}
}
}
impl Encoder for Script {
fn encode(&mut self, nv12: Vec<u8>, time: u64, key: bool) -> Pending<Vec<Unit>> {
let bytes = js_sys::Uint8Array::from(&nv12[..]);
self.call("encode", &js_sys::Array::of3(&bytes, &(time as f64).into(), &key.into()))
}
fn finish(&mut self) -> Pending<Vec<Unit>> {
self.call("finish", &js_sys::Array::new())
}
}
const PAD: f64 = 2.0;
fn lines(
context: &OffscreenCanvasRenderingContext2d,
text: &str,
max_width: Option<f64>,
) -> Option<Vec<(String, f64)>> {
let width = |s: &str| context.measure_text(s).ok().map(|m| m.width());
let Some(max_width) = max_width else { return Some(vec![(text.to_owned(), width(text)?)]) };
let mut lines: Vec<(String, f64)> = Vec::new();
for word in text.split(' ') {
if let Some((line, w)) = lines.last_mut() {
let joined = format!("{line} {word}");
let joined_width = width(&joined)?;
if joined_width <= max_width {
*line = joined;
*w = joined_width;
continue;
}
}
lines.push((word.to_owned(), width(word)?));
}
Some(lines)
}
fn with_context<R>(f: impl FnOnce(&OffscreenCanvas, &OffscreenCanvasRenderingContext2d) -> Option<R>) -> Option<R> {
CANVAS.with(|cell| {
let mut cell = cell.borrow_mut();
if cell.is_none() {
let canvas = OffscreenCanvas::new(256, 64).ok()?;
let options = js_sys::Object::new();
js_sys::Reflect::set(&options, &"willReadFrequently".into(), &true.into()).ok()?;
let context = canvas.get_context_with_context_options("2d", &options).ok()??;
let context = context.dyn_into::<OffscreenCanvasRenderingContext2d>().ok()?;
*cell = Some((canvas, context));
}
let (canvas, context) = cell.as_ref()?;
f(canvas, context)
})
}
struct Layout {
lines: Vec<(String, f64)>,
ascent: f64,
line_height: f64,
}
impl Layout {
fn of(context: &OffscreenCanvasRenderingContext2d, run: &TextRun) -> Option<Layout> {
context.set_font(&format!("{}px {}", run.size_px, run.font));
let lines = lines(context, &run.text, run.max_width.map(f64::from))?;
let metrics = context.measure_text(&run.text).ok()?;
let ascent = metrics.font_bounding_box_ascent();
Some(Layout { lines, ascent, line_height: ascent + metrics.font_bounding_box_descent() })
}
fn size(&self) -> [f64; 2] {
let widest = self.lines.iter().map(|(_, w)| *w).fold(0.0, f64::max);
[widest + 2.0 * PAD, self.line_height * self.lines.len() as f64 + 2.0 * PAD]
}
}
impl Host for Browser {
fn measure(&self, run: &TextRun) -> crate::Result<[f32; 2]> {
with_context(|_, context| Some(Layout::of(context, run)?.size().map(|v| v as f32)))
.ok_or_else(|| Error::Unsupported("browser text measurement failed".into()))
}
fn spawn(&self, job: Job) -> Pending<Vec<u8>> {
let Some(run) = JOBS.with(|jobs| jobs.borrow().clone()) else { return Pending::ready(Ok(job.run())) };
let bytes = js_sys::Uint8Array::from(&job.into_bytes()[..]);
let promise = match run.call1(&JsValue::NULL, &bytes).map(js_sys::Promise::from) {
Ok(promise) => promise,
Err(error) => return Pending::ready(Err(Error::Unsupported(format!("jobs: {error:?}").into()))),
};
let (pending, resolver) = Pending::new();
wasm_bindgen_futures::spawn_local(async move {
let answer = wasm_bindgen_futures::JsFuture::from(promise).await;
resolver.resolve(
answer
.map(|bytes| js_sys::Uint8Array::new(&bytes).to_vec())
.map_err(|error| Error::Unsupported(format!("a job failed: {error:?}").into())),
);
});
pending
}
fn fetch(&self, request: &Request) -> Pending<Vec<u8>> {
let (pending, resolver) = Pending::new();
let request = request.clone();
wasm_bindgen_futures::spawn_local(async move {
resolver.resolve(fetched(&request).await);
});
pending
}
fn rasterize(&self, run: &TextRun) -> crate::Result<Raster> {
with_context(|canvas, context| {
let layout = Layout::of(context, run)?;
let size = layout.size().map(|v| v.ceil() as u32);
if canvas.width() < size[0] || canvas.height() < size[1] {
canvas.set_width(canvas.width().max(size[0].next_power_of_two()));
canvas.set_height(canvas.height().max(size[1].next_power_of_two()));
}
context.set_font(&format!("{}px {}", run.size_px, run.font));
context.set_text_baseline("alphabetic");
context.set_fill_style_str("#fff");
context.clear_rect(0.0, 0.0, f64::from(size[0]), f64::from(size[1]));
for (i, (line, _)) in layout.lines.iter().enumerate() {
context.fill_text(line, PAD, PAD + layout.ascent + layout.line_height * i as f64).ok()?;
}
let rgba = context.get_image_data(0.0, 0.0, f64::from(size[0]), f64::from(size[1])).ok()?.data();
let coverage = rgba.0.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
Some(Raster { size, coverage, baseline: (PAD + layout.ascent) as f32 })
})
.ok_or_else(|| Error::Unsupported("browser text rasterization failed".into()))
}
fn encoder(&self, encoding: &Encoding) -> crate::Result<Box<dyn Encoder>> {
let unsupported =
|| Error::Unsupported("videos in a browser encode with WebCodecs, which the page did not give".into());
let create = ENCODERS.with(|encoders| encoders.borrow().clone()).ok_or_else(unsupported)?;
let [width, height] = encoding.size;
let args = js_sys::Array::of4(
&width.into(),
&height.into(),
&encoding.fps.into(),
&f64::from(encoding.bitrate).into(),
);
let encoder = create
.apply(&JsValue::NULL, &args)
.map_err(|error| Error::Unsupported(format!("no encoder for {width}×{height}: {error:?}").into()))?;
Ok(Box::new(Script(encoder)))
}
}
async fn fetched(request: &Request) -> crate::Result<Vec<u8>> {
let failed = |what: &str| Error::Io(format!("{} could not be read: {what}", request.url).into());
let init = web_sys::RequestInit::new();
let headers = web_sys::Headers::new().map_err(|e| failed(&format!("{e:?}")))?;
if let Some(range) = &request.range {
let value = format!("bytes={}-{}", range.start, range.end.saturating_sub(1));
headers.set("Range", &value).map_err(|e| failed(&format!("{e:?}")))?;
}
init.set_headers(&headers);
let fetch = web_sys::Request::new_with_str_and_init(&request.url, &init).map_err(|e| failed(&format!("{e:?}")))?;
let global = js_sys::global();
let promise = match (global.dyn_ref::<web_sys::Window>(), global.dyn_ref::<web_sys::WorkerGlobalScope>()) {
(Some(window), _) => window.fetch_with_request(&fetch),
(None, Some(worker)) => worker.fetch_with_request(&fetch),
(None, None) => return Err(failed("no fetch here")),
};
let response = wasm_bindgen_futures::JsFuture::from(promise).await.map_err(|e| failed(&format!("{e:?}")))?;
let response: web_sys::Response = response.dyn_into().map_err(|_| failed("not a response"))?;
match response.status() {
200 | 206 => {}
204 | 404 => return Ok(Vec::new()),
status => return Err(failed(&format!("status {status}"))),
}
let body = response.array_buffer().map_err(|e| failed(&format!("{e:?}")))?;
let body = wasm_bindgen_futures::JsFuture::from(body).await.map_err(|e| failed(&format!("{e:?}")))?;
let mut bytes = js_sys::Uint8Array::new(&body).to_vec();
if response.status() == 200
&& let Some(range) = &request.range
{
let end = (range.end as usize).min(bytes.len());
bytes = bytes.get(range.start as usize..end).map(<[u8]>::to_vec).unwrap_or_default();
}
Ok(bytes)
}