Got the movie working in the gallery
This commit is contained in:
1 parent
8861858f0d
commit
782820261e
15 files changed
+1291
-111
No files matched your search
Generated
+12
@@ -1579,6 +1579,18 @@ dependencies = [
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolved-showcase"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anise",
|
||||
"astrokit",
|
||||
"clap",
|
||||
"hifitime",
|
||||
"image",
|
||||
"resolved",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roots"
|
||||
version = "0.0.8"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/resolved", "crates/resolved-cli"]
|
||||
members = ["crates/resolved", "crates/resolved-cli", "crates/resolved-showcase"]
|
||||
# "3" picks dependency versions that support rust-version below.
|
||||
resolver = "3"
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ use std::time::Instant;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use resolved::gpu::AdapterChoice;
|
||||
use resolved::radiometry::{Radiometry, AU};
|
||||
use resolved::{
|
||||
CameraTier, EarthTexture, Fidelity, Frame, LightingTier, MoonTexture, Renderer, RendererOptions,
|
||||
CameraTier, EarthTexture, Fidelity, Frame, LightingTier, Look, MoonTexture, Preview, Renderer,
|
||||
RendererOptions,
|
||||
};
|
||||
|
||||
use crate::scene_file::SceneFile;
|
||||
@@ -270,24 +270,8 @@ fn render(args: &RenderArgs) -> Result<(), String> {
|
||||
write_dn(&frame, out, detector.adc_bits)?;
|
||||
}
|
||||
if let Some(preview) = preview {
|
||||
// Bias reads as black and the saturation level as white.
|
||||
let black = detector.bias;
|
||||
let white = detector.dn(detector.full_well) as f64;
|
||||
let look = if args.pretty {
|
||||
// Each channel scaled to the weakest one's response to a white surface in
|
||||
// sunlight, so sunlit white comes out neutral and no channel is amplified.
|
||||
let radiometry = Radiometry::new(renderer.camera());
|
||||
let white_surface: Vec<f64> = (0..frame.channels as usize)
|
||||
.map(|k| radiometry.lambert_radiance(k, 1.0, 1.0, AU))
|
||||
.collect();
|
||||
let weakest = white_surface.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
Look::Pretty {
|
||||
balance: white_surface.iter().map(|w| weakest / w).collect(),
|
||||
}
|
||||
} else {
|
||||
Look::Raw
|
||||
};
|
||||
write_preview(&frame, preview, black, white, &look)?;
|
||||
let look = if args.pretty { Look::Pretty } else { Look::Raw };
|
||||
write_preview(&frame, preview, &Preview::new(renderer.camera(), look))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -323,62 +307,8 @@ fn write_dn(frame: &Frame, path: &Path, adc_bits: u32) -> Result<(), String> {
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// How the preview turns DN into an image.
|
||||
enum Look {
|
||||
/// The signal as recorded: linear from the bias (black) to saturation (white).
|
||||
Raw,
|
||||
/// Like a photograph: each channel scaled by `balance`, then [`tone`].
|
||||
Pretty { balance: Vec<f64> },
|
||||
}
|
||||
|
||||
/// How much the pretty tone curve brightens the darkest values: 3, about 1.6 stops.
|
||||
const PRETTY_LIFT: f64 = 3.0;
|
||||
|
||||
/// The pretty tone curve, from the fraction of saturation `x` to linear display value:
|
||||
/// Reinhard's operator with its white point at saturation. It starts with slope
|
||||
/// [`PRETTY_LIFT`] and rises ever more slowly to 1 at `x = 1`, so midtones brighten
|
||||
/// and highlights compress instead of clipping.
|
||||
fn tone(x: f64) -> f64 {
|
||||
let x = x.max(0.0);
|
||||
PRETTY_LIFT * x * (1.0 + x / PRETTY_LIFT) / (1.0 + PRETTY_LIFT * x)
|
||||
}
|
||||
|
||||
fn write_preview(
|
||||
frame: &Frame,
|
||||
path: &Path,
|
||||
black: f64,
|
||||
white: f64,
|
||||
look: &Look,
|
||||
) -> Result<(), String> {
|
||||
let srgb = |x: f64| {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
let v = if x <= 0.003_130_8 {
|
||||
12.92 * x
|
||||
} else {
|
||||
1.055 * x.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(v * 255.0).round() as u8
|
||||
};
|
||||
let level = |d: u16| (d as f64 - black) / (white - black);
|
||||
let bytes: Vec<u8> = frame
|
||||
.dn
|
||||
.chunks(frame.channels as usize)
|
||||
.flat_map(|pixel| -> Vec<u8> {
|
||||
match look {
|
||||
Look::Raw => pixel.iter().map(|&d| srgb(level(d))).collect(),
|
||||
// A channel at saturation lost how bright it was, and balancing the
|
||||
// others would tint it: saturated pixels are white.
|
||||
Look::Pretty { .. } if pixel.iter().any(|&d| d as f64 >= white) => {
|
||||
vec![255; pixel.len()]
|
||||
}
|
||||
Look::Pretty { balance } => pixel
|
||||
.iter()
|
||||
.zip(balance)
|
||||
.map(|(&d, b)| srgb(tone(level(d) * b)))
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
fn write_preview(frame: &Frame, path: &Path, preview: &Preview) -> Result<(), String> {
|
||||
let bytes = preview.srgb8(&frame.dn, frame.width);
|
||||
image::save_buffer(
|
||||
path,
|
||||
&bytes,
|
||||
@@ -388,22 +318,3 @@ fn write_preview(
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tone_lifts_the_darks_and_reaches_white_at_saturation() {
|
||||
assert_eq!(tone(0.0), 0.0);
|
||||
assert!((tone(1.0) - 1.0).abs() < 1e-12);
|
||||
// Slope PRETTY_LIFT at zero; brighter than raw everywhere between.
|
||||
assert!((tone(1e-6) / 1e-6 - PRETTY_LIFT).abs() < 1e-3);
|
||||
let mut last = 0.0;
|
||||
for i in 1..100 {
|
||||
let x = i as f64 / 100.0;
|
||||
assert!(tone(x) > x && tone(x) > last);
|
||||
last = tone(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "resolved-showcase"
|
||||
description = "A showcase movie for the resolved renderer: a propagated flight around a spacecraft"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "resolved-movie"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
resolved = { workspace = true, features = ["astrokit"] }
|
||||
# Not on crates.io, so a path dependency; a sibling checkout is expected, as for the
|
||||
# library.
|
||||
astrokit = { path = "../../../astrokit", default-features = false }
|
||||
# The kernels' type, as astrokit and the library use it.
|
||||
anise = { version = "0.10.6", default-features = false }
|
||||
hifitime = "4.3"
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -0,0 +1,327 @@
|
||||
//! The flight: a target in low Earth orbit, propagated by astrokit with J2, and an
|
||||
//! inspector holding station behind and above it with the camera, sampled once per
|
||||
//! movie frame.
|
||||
//!
|
||||
//! The camera holds a station in the target's local frame (as an inspection spacecraft
|
||||
//! does by thrusting), looking forward and down past the target toward the horizon
|
||||
//! ahead, so the limb crosses the frame just above the target. On top of the station it
|
||||
//! moves as a free spacecraft would: a deputy, propagated by astrokit alongside the
|
||||
//! target, flies a small drift-free relative ellipse around it (the Clohessy-Wiltshire
|
||||
//! solution with no along-track drift), and the camera follows the deputy's offset from
|
||||
//! the target. The orbit's plane holds both the Sun and the Moon.
|
||||
//!
|
||||
//! Time runs at a varying rate, like a documentary's time-lapse: fast while cruising
|
||||
//! over the daylit Earth, slowing as the Moon rises over the limb ahead, and slowing
|
||||
//! nearly to real time as the target passes into Earth's shadow, so the penumbra can
|
||||
//! be seen darkening it. The two moments are found on the propagated orbit, and the
|
||||
//! cruising rate is solved so they fall at [`MOONRISE_AT`] and [`SUNSET_AT`].
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anise::almanac::Almanac;
|
||||
use astrokit::prelude::*;
|
||||
use resolved::ephemeris::Ephemeris;
|
||||
use resolved::nalgebra::{Matrix3, Rotation3, UnitQuaternion, Vector3};
|
||||
use resolved::Pose;
|
||||
|
||||
/// Earth's equatorial radius, in meters.
|
||||
const EARTH_RADIUS: f64 = 6_378_137.0;
|
||||
|
||||
/// The target's orbit radius: 705 km up, Landsat 8's altitude.
|
||||
pub const ORBIT_RADIUS: f64 = EARTH_RADIUS + 705e3;
|
||||
|
||||
/// The movie's length, in seconds.
|
||||
pub const MOVIE: f64 = 30.0;
|
||||
|
||||
/// When the Moon's center stands MOON_CLEAR above the limb ahead, in movie seconds:
|
||||
/// clear of the horizon, so the slow passage shows it rising rather than still hidden.
|
||||
pub const MOONRISE_AT: f64 = 9.0;
|
||||
const MOON_CLEAR: f64 = 2.0 * PI / 180.0;
|
||||
/// When the target enters Earth's shadow, in movie seconds.
|
||||
pub const SUNSET_AT: f64 = 26.5;
|
||||
/// Simulated seconds per movie second as the Moon rises, and as the target sets.
|
||||
const MOONRISE_RATE: f64 = 8.0;
|
||||
const SUNSET_RATE: f64 = 1.5;
|
||||
/// How long each slow passage lasts, in movie seconds: the moonrise's is the half
|
||||
/// width of a flat-topped window (exp(-x^6 / 2)), since the Moon has the whole sky above
|
||||
/// the limb to climb through at the orbit's rate; the sunset's, the standard deviation
|
||||
/// of a Gaussian.
|
||||
const MOONRISE_SPREAD: f64 = 5.0;
|
||||
const SUNSET_SPREAD: f64 = 2.0;
|
||||
|
||||
/// The camera station's distance from the target, and its line of sight below the
|
||||
/// local horizontal (1.2 degrees steeper than the horizon, so the limb is just above
|
||||
/// the target).
|
||||
const RANGE: f64 = 120.0;
|
||||
const DEPRESSION: f64 = 27.0 * PI / 180.0;
|
||||
/// The relative ellipse's radial amplitude (it is twice that along-track) and its
|
||||
/// amplitude out of the orbit plane, in meters. Small: at RANGE, a meter radially
|
||||
/// tilts the view by half a degree, and the limb is only 1.2 degrees above center.
|
||||
const RELATIVE_RADIAL: f64 = 5.0;
|
||||
const RELATIVE_CROSS: f64 = 30.0;
|
||||
/// Where the relative ellipse has the camera across the orbit plane at the moonrise,
|
||||
/// in meters: to one side, so the Moon comes up beside the target instead of behind
|
||||
/// it and its solar array.
|
||||
const CROSS_AT_MOONRISE: f64 = 8.0;
|
||||
|
||||
/// One movie frame's geometry.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Shot {
|
||||
/// The frame's epoch.
|
||||
pub epoch: Epoch,
|
||||
/// The target's pose: body axes along-track, orbit normal and zenith.
|
||||
pub target: Pose,
|
||||
/// The camera: at the inspector, looking at the target's center.
|
||||
pub camera: Pose,
|
||||
}
|
||||
|
||||
/// What the flight works out about itself.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Plan {
|
||||
/// The angle between the Sun and the Moon seen from Earth, in radians.
|
||||
pub elongation: f64,
|
||||
/// Simulated seconds per movie second while cruising.
|
||||
pub cruise: f64,
|
||||
/// Simulated time the movie covers, in seconds.
|
||||
pub span: f64,
|
||||
}
|
||||
|
||||
/// Simulated seconds per movie second at movie time `tau`, with `cruise` between the
|
||||
/// slow passages.
|
||||
fn rate(tau: f64, cruise: f64) -> f64 {
|
||||
let m = (-0.5 * ((tau - MOONRISE_AT) / MOONRISE_SPREAD).powi(6)).exp();
|
||||
let s = (-0.5 * ((tau - SUNSET_AT) / SUNSET_SPREAD).powi(2)).exp();
|
||||
cruise * (1.0 - m - s).max(0.0) + MOONRISE_RATE * m + SUNSET_RATE * s
|
||||
}
|
||||
|
||||
/// Simulated time elapsed from movie time `from` to `to`, at `cruise`.
|
||||
fn elapsed(from: f64, to: f64, cruise: f64) -> f64 {
|
||||
let steps = ((to - from) * 1000.0).ceil().max(1.0) as usize;
|
||||
let h = (to - from) / steps as f64;
|
||||
(0..steps)
|
||||
.map(|i| rate(from + (i as f64 + 0.5) * h, cruise) * h)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The first time in `[lo, hi]` where `f` changes sign from `f(lo)`, by bisection.
|
||||
fn crossing(lo: f64, hi: f64, f: impl Fn(f64) -> f64) -> Option<f64> {
|
||||
// Scan in 5 s steps for the sign change, then bisect.
|
||||
let start = f(lo).signum();
|
||||
let mut a = lo;
|
||||
while a < hi {
|
||||
let b = (a + 5.0).min(hi);
|
||||
if f(b).signum() != start {
|
||||
let (mut a, mut b) = (a, b);
|
||||
for _ in 0..50 {
|
||||
let mid = 0.5 * (a + b);
|
||||
if f(mid).signum() == start {
|
||||
a = mid;
|
||||
} else {
|
||||
b = mid;
|
||||
}
|
||||
}
|
||||
return Some(0.5 * (a + b));
|
||||
}
|
||||
a = b;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every frame of the flight, `fps` per movie second, propagated with `almanac`'s
|
||||
/// kernels (the ones `ephemeris` reads). `epoch` sets the Sun and the Moon
|
||||
/// and is where propagation starts, `lead` seconds before the moonrise is due; `center`
|
||||
/// is the target model's center in its body axes, which the camera keeps in the middle
|
||||
/// of the image.
|
||||
pub fn fly(
|
||||
almanac: Arc<Almanac>,
|
||||
ephemeris: &Ephemeris,
|
||||
epoch: Epoch,
|
||||
center: Vector3<f64>,
|
||||
fps: u32,
|
||||
) -> Result<(Plan, Vec<Shot>), String> {
|
||||
let error = |e: resolved::Error| e.to_string();
|
||||
let sun = ephemeris.sun(epoch).map_err(error)?;
|
||||
let moon = ephemeris.moon(epoch).map_err(error)?;
|
||||
let mu = Body::EARTH.mu;
|
||||
let n = (mu / ORBIT_RADIUS.powi(3)).sqrt();
|
||||
|
||||
// The orbit plane, with angles measured from the Moon and the Sun at -elongation,
|
||||
// so the target moves away from the Sun's side toward the Moon's.
|
||||
let m = moon.normalize();
|
||||
let elongation = sun.normalize().dot(&m).clamp(-1.0, 1.0).acos();
|
||||
let (x, y) = (m, -(sun - sun.dot(&m) * m).normalize());
|
||||
let at = |angle: f64| x * angle.cos() + y * angle.sin();
|
||||
|
||||
// Start well before the moonrise: the line of sight grazing the limb ahead points
|
||||
// 90 degrees ahead of the target's position, less the horizon's dip.
|
||||
let dip = (EARTH_RADIUS / ORBIT_RADIUS).acos();
|
||||
let lead = 2400.0;
|
||||
let start = -(FRAC_PI_2 - dip) - n * lead;
|
||||
// Long enough to reach the shadow from there and run on past it.
|
||||
let length = lead + (PI - elongation) / n + 2400.0;
|
||||
|
||||
let target = Cartesian::new(
|
||||
at(start) * ORBIT_RADIUS,
|
||||
at(start + FRAC_PI_2) * (n * ORBIT_RADIUS),
|
||||
Frame::EARTH_J2000,
|
||||
);
|
||||
let forces = [Zonal {
|
||||
field: GravityModel::Egm96,
|
||||
degree: 2,
|
||||
}];
|
||||
let mut run = Propagation::from_cartesian(epoch, target, &forces)
|
||||
.almanac(almanac.clone())
|
||||
.duration(Duration::from_seconds(length))
|
||||
.accuracy(Accuracy::High);
|
||||
let solution = run.run();
|
||||
let state = |t: f64| {
|
||||
Orbit
|
||||
.state(solution.interpolate(t))
|
||||
.cartesian(Frame::EARTH_J2000)
|
||||
};
|
||||
let when = |t: f64| astrokit::time::advance(epoch, t);
|
||||
|
||||
// The moonrise: the Moon's center MOON_CLEAR above the horizon ahead.
|
||||
let moon_height = |t: f64| {
|
||||
let p = state(t).position;
|
||||
let to_moon = (ephemeris.moon(when(t)).expect("in the kernels") - p).normalize();
|
||||
to_moon.dot(&p.normalize()).asin() + (EARTH_RADIUS / p.norm()).acos() - MOON_CLEAR
|
||||
};
|
||||
let moonrise = crossing(0.0, length, moon_height)
|
||||
.ok_or("the Moon never rises over the limb ahead; try another epoch")?;
|
||||
// The sunset: the target entering Earth's (cylindrical) shadow.
|
||||
let sunlit = |t: f64| {
|
||||
let p = state(t).position;
|
||||
let s = ephemeris.sun(when(t)).expect("in the kernels").normalize();
|
||||
let along = p.dot(&s);
|
||||
if along > 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
(p - along * s).norm() - EARTH_RADIUS
|
||||
}
|
||||
};
|
||||
let sunset = crossing(moonrise, length, sunlit)
|
||||
.ok_or("the target never reaches Earth's shadow; try another epoch")?;
|
||||
|
||||
// The cruising rate that puts the sunset at SUNSET_AT, given the moonrise at
|
||||
// MOONRISE_AT: elapsed time is linear in it.
|
||||
let base = elapsed(MOONRISE_AT, SUNSET_AT, 0.0);
|
||||
let per = elapsed(MOONRISE_AT, SUNSET_AT, 1.0) - base;
|
||||
let cruise = (sunset - moonrise - base) / per;
|
||||
if cruise <= MOONRISE_RATE {
|
||||
return Err(format!(
|
||||
"the moonrise and the sunset are only {:.0} s apart; try another epoch",
|
||||
sunset - moonrise
|
||||
));
|
||||
}
|
||||
let first = moonrise - elapsed(0.0, MOONRISE_AT, cruise);
|
||||
let last = sunset + elapsed(SUNSET_AT, MOVIE, cruise);
|
||||
if first < 0.0 || last > length {
|
||||
return Err("the flight does not fit the propagated span".into());
|
||||
}
|
||||
|
||||
// The deputy: on a drift-free relative ellipse around the target. In the target's
|
||||
// plane the motion is the Clohessy-Wiltshire ellipse, phased to cross the station's
|
||||
// height halfway through the flight, so the camera spends as long above it as
|
||||
// below: with u = n (t - middle), radial A sin u and along-track 2A cos u. Across
|
||||
// the plane it is an independent oscillation, phased to be CROSS_AT_MOONRISE out
|
||||
// at the moonrise: with w = n (t - moonrise) + asin(CROSS_AT_MOONRISE / B),
|
||||
// cross-track B sin w.
|
||||
let deputy = {
|
||||
let start = state(0.0);
|
||||
let (r, v) = (start.position, start.velocity);
|
||||
let (zenith, normal) = (r.normalize(), r.cross(&v).normalize());
|
||||
let along = normal.cross(&zenith);
|
||||
let rate = r.cross(&v).norm() / r.norm_squared();
|
||||
let (a, b) = (RELATIVE_RADIAL, RELATIVE_CROSS);
|
||||
let u = -rate * (first + last) / 2.0;
|
||||
let w = -rate * moonrise + (CROSS_AT_MOONRISE / b).asin();
|
||||
let offset = zenith * (a * u.sin()) + along * (2.0 * a * u.cos()) + normal * (b * w.sin());
|
||||
let drift = zenith * (a * rate * u.cos())
|
||||
+ along * (-2.0 * a * rate * u.sin())
|
||||
+ normal * (b * rate * w.cos());
|
||||
// Rates in the rotating frame, plus the frame's own turning.
|
||||
let velocity = v + drift + (normal * rate).cross(&offset);
|
||||
Cartesian::new(r + offset, velocity, Frame::EARTH_J2000)
|
||||
};
|
||||
let mut deputy_run = Propagation::from_cartesian(epoch, deputy, &forces)
|
||||
.almanac(almanac)
|
||||
.duration(Duration::from_seconds(length))
|
||||
.accuracy(Accuracy::High);
|
||||
let deputy_solution = deputy_run.run();
|
||||
let deputy_position = |t: f64| {
|
||||
Orbit
|
||||
.state(deputy_solution.interpolate(t))
|
||||
.cartesian(Frame::EARTH_J2000)
|
||||
.position
|
||||
};
|
||||
|
||||
let frames = (MOVIE * fps as f64).round() as usize;
|
||||
let mut shots = Vec::with_capacity(frames);
|
||||
let mut t = first;
|
||||
for k in 0..frames {
|
||||
let tau = k as f64 / fps as f64;
|
||||
if k > 0 {
|
||||
t += elapsed(tau - 1.0 / fps as f64, tau, cruise);
|
||||
}
|
||||
let target = state(t);
|
||||
// Target axes: x along-track, y the orbit normal, z zenith.
|
||||
let zenith = target.position.normalize();
|
||||
let normal = target.position.cross(&target.velocity).normalize();
|
||||
let along = normal.cross(&zenith);
|
||||
let attitude = UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(
|
||||
Matrix3::from_columns(&[along, normal, zenith]),
|
||||
));
|
||||
let aim = target.position + attitude * center;
|
||||
// The station, behind and above, looking DEPRESSION down, moved by the deputy's
|
||||
// natural motion relative to the target.
|
||||
let station = aim - along * (RANGE * DEPRESSION.cos())
|
||||
+ zenith * (RANGE * DEPRESSION.sin())
|
||||
+ (deputy_position(t) - target.position);
|
||||
let camera = Pose::looking_at(station, aim, zenith)
|
||||
.expect("the line of sight is never along the zenith");
|
||||
shots.push(Shot {
|
||||
epoch: when(t),
|
||||
target: Pose::new(target.position, attitude),
|
||||
camera,
|
||||
});
|
||||
}
|
||||
Ok((
|
||||
Plan {
|
||||
elongation,
|
||||
cruise,
|
||||
span: last - first,
|
||||
},
|
||||
shots,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn time_slows_to_the_set_rates_at_the_two_moments() {
|
||||
let cruise = 200.0;
|
||||
assert!((rate(MOONRISE_AT, cruise) - MOONRISE_RATE).abs() < 1e-9);
|
||||
assert!((rate(SUNSET_AT, cruise) - SUNSET_RATE).abs() < 1.0);
|
||||
// Between them it is well above both and never above the cruising rate.
|
||||
let between = rate((MOONRISE_AT + SUNSET_AT) / 2.0, cruise);
|
||||
assert!(between > 10.0 * MOONRISE_RATE && between <= cruise);
|
||||
// Elapsed time adds up across a split, and is linear in the cruising rate.
|
||||
let whole = elapsed(0.0, MOVIE, cruise);
|
||||
let split = elapsed(0.0, 12.0, cruise) + elapsed(12.0, MOVIE, cruise);
|
||||
assert!((whole - split).abs() / whole < 1e-9);
|
||||
let (a, b) = (elapsed(2.0, 20.0, 100.0), elapsed(2.0, 20.0, 300.0));
|
||||
let mid = elapsed(2.0, 20.0, 200.0);
|
||||
assert!((mid - (a + b) / 2.0).abs() / mid < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_finds_the_first_sign_change() {
|
||||
let t = crossing(0.0, 100.0, |t| (t - 37.25) * (t - 80.0)).unwrap();
|
||||
assert!((t - 37.25).abs() < 1e-9);
|
||||
assert!(crossing(0.0, 30.0, |t| t + 1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! `resolved-movie`: a showcase of the renderer, as a movie of a propagated flight.
|
||||
//!
|
||||
//! An inspector carrying the example camera flies around a Landsat 8 model in low
|
||||
//! orbit, both propagated by astrokit with J2 (see [`flight`]), rendered at lighting L1:
|
||||
//! Earth's surface turning below, the Moon rising over the limb, shadows moving across
|
||||
//! the spacecraft and, at the end, the spacecraft passing into Earth's shadow.
|
||||
//!
|
||||
//! `--out movie.mp4` renders every frame and encodes them with ffmpeg. `--serve
|
||||
//! ADDRESS` renders live, in step with the wall clock, and streams the frames as MJPEG
|
||||
//! to any browser that opens the address, looping the flight; it renders only while
|
||||
//! someone is watching.
|
||||
|
||||
mod flight;
|
||||
mod stream;
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use astrokit::prelude::Epoch;
|
||||
use clap::Parser;
|
||||
use resolved::ephemeris::Ephemeris;
|
||||
use resolved::gpu::AdapterChoice;
|
||||
use resolved::nalgebra::Vector3;
|
||||
use resolved::{
|
||||
CameraConfig, Detector, EarthTexture, Fidelity, LightingTier, LoadOptions, Look, Mesh,
|
||||
MoonTexture, Preview, Renderer, RendererOptions, Scene, Target,
|
||||
};
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "A showcase movie: a propagated flight around a spacecraft"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Write the movie here as H.264 MP4, through ffmpeg.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Render live and stream to browsers at this address, such as 0.0.0.0:8010.
|
||||
#[arg(long, value_name = "ADDRESS")]
|
||||
serve: Option<String>,
|
||||
/// Frames per second.
|
||||
#[arg(long, default_value_t = 60)]
|
||||
fps: u32,
|
||||
/// Sets the Sun and the Moon; the flight starts 40 minutes before the Moon rises
|
||||
/// over the limb ahead. The Moon should be 90 to 120 degrees from the Sun, so it is
|
||||
/// about half lit, Earth below is in daylight, and the target reaches Earth's
|
||||
/// shadow before the movie ends.
|
||||
#[arg(long, default_value = "2026-01-10T03:00:00 UTC")]
|
||||
epoch: String,
|
||||
/// The lens's focal length, in meters.
|
||||
#[arg(long, default_value_t = 0.035)]
|
||||
focal_length: f64,
|
||||
/// The exposure, in seconds: where auto exposure starts, or with --fixed-exposure,
|
||||
/// the exposure throughout.
|
||||
#[arg(long, default_value_t = 2e-4)]
|
||||
exposure: f64,
|
||||
/// Keep the exposure fixed instead of adjusting it frame by frame.
|
||||
#[arg(long)]
|
||||
fixed_exposure: bool,
|
||||
/// Show the raw signal instead of the pretty look (see `resolved render --pretty`).
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
/// Device: auto, gpu, cpu, or part of an adapter's name.
|
||||
#[arg(long, default_value = "auto")]
|
||||
adapter: String,
|
||||
/// Directory with earth/bmng-2004-MM.jpg and moon/wac-hapke-643.png. Without it,
|
||||
/// the repository's data/.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
data: Option<PathBuf>,
|
||||
/// Directory of astrokit's kernels. Without it: ASTROKIT_KERNELS, then
|
||||
/// ./data/kernels, then the sibling astrokit checkout.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
kernels: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run(&Cli::parse()) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn repository() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn load_almanac(explicit: Option<&Path>) -> Result<Arc<anise::almanac::Almanac>> {
|
||||
let directories: Vec<PathBuf> = match explicit {
|
||||
Some(dir) => vec![dir.to_owned()],
|
||||
None => std::env::var_os("ASTROKIT_KERNELS")
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.chain([
|
||||
PathBuf::from("data/kernels"),
|
||||
repository().join("../astrokit/data/kernels"),
|
||||
])
|
||||
.collect(),
|
||||
};
|
||||
let mut failures = Vec::new();
|
||||
for dir in &directories {
|
||||
match astrokit::kernels::load(dir) {
|
||||
Ok(almanac) => return Ok(Arc::new(almanac)),
|
||||
Err(e) => failures.push(format!(" {}: {e}", dir.display())),
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"astrokit kernels not found. Pass --kernels DIR or set ASTROKIT_KERNELS; astrokit's \
|
||||
scripts/fetch-kernels.sh downloads them. Tried:\n{}",
|
||||
failures.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
/// Auto exposure: after each frame, steer the next frame's exposure so the brightest
|
||||
/// parts of the image (its 99.9th percentile) sit at LEVEL of full scale (below half,
|
||||
/// since the pretty look lifts what is below it), changing by at most a factor of
|
||||
/// STEP per second of movie, between the shortest exposure and one frame's time, as a
|
||||
/// video camera is limited to.
|
||||
const LEVEL: f64 = 0.45;
|
||||
const STEP: f64 = 6.0;
|
||||
const SHORTEST: f64 = 1e-5;
|
||||
|
||||
/// Everything a frame needs, set up once.
|
||||
struct Movie {
|
||||
renderer: Renderer,
|
||||
preview: Preview,
|
||||
/// The longest exposure, or None to keep it fixed.
|
||||
auto_exposure: Option<f64>,
|
||||
/// The most auto exposure changes from one frame to the next, as a factor.
|
||||
step: f64,
|
||||
detector: Detector,
|
||||
/// One per frame, with its camera.
|
||||
frames: Vec<(Scene, resolved::Pose)>,
|
||||
}
|
||||
|
||||
impl Movie {
|
||||
fn new(cli: &Cli) -> Result<Self> {
|
||||
let started = Instant::now();
|
||||
let almanac = load_almanac(cli.kernels.as_deref())?;
|
||||
let ephemeris = Ephemeris::new(almanac.clone());
|
||||
let epoch: Epoch = cli
|
||||
.epoch
|
||||
.parse()
|
||||
.map_err(|e| format!("epoch {:?}: {e}", cli.epoch))?;
|
||||
let error = |e: resolved::Error| e.to_string();
|
||||
|
||||
let model = repository().join("examples/models/nasa/landsat8.glb");
|
||||
let mesh = Mesh::load(&model, &LoadOptions::default())
|
||||
.map_err(|e| format!("{e} (scripts/fetch-models.sh downloads the model)"))?;
|
||||
let (mut lo, mut hi) = (Vector3::repeat(f64::MAX), Vector3::repeat(f64::MIN));
|
||||
for p in &mesh.positions {
|
||||
let p = Vector3::from(*p).cast::<f64>();
|
||||
lo = lo.inf(&p);
|
||||
hi = hi.sup(&p);
|
||||
}
|
||||
|
||||
let (plan, shots) = flight::fly(almanac, &ephemeris, epoch, (lo + hi) / 2.0, cli.fps)?;
|
||||
|
||||
let data = cli
|
||||
.data
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository().join("data"));
|
||||
let month = epoch.to_gregorian_utc().1;
|
||||
let earth = data.join(format!("earth/bmng-2004-{month:02}.jpg"));
|
||||
let moon = data.join("moon/wac-hapke-643.png");
|
||||
let earth = EarthTexture::load(&earth)
|
||||
.map_err(|e| format!("{e} (scripts/fetch-earth.sh downloads it)"))?;
|
||||
let moon = MoonTexture::load(&moon)
|
||||
.map_err(|e| format!("{e} (scripts/fetch-moon.py makes it)"))?;
|
||||
|
||||
let mut camera = CameraConfig::example();
|
||||
camera.exposure = cli.exposure;
|
||||
camera.focal_length = cli.focal_length;
|
||||
let adapter = match cli.adapter.as_str() {
|
||||
"auto" => AdapterChoice::Auto,
|
||||
"gpu" => AdapterChoice::Gpu,
|
||||
"cpu" => AdapterChoice::Cpu,
|
||||
name => AdapterChoice::Named(name.to_owned()),
|
||||
};
|
||||
let options = RendererOptions {
|
||||
adapter,
|
||||
fidelity: Fidelity {
|
||||
lighting: LightingTier::L1,
|
||||
..Default::default()
|
||||
},
|
||||
earth_texture: Some(Arc::new(earth)),
|
||||
moon_texture: Some(Arc::new(moon)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut renderer = Renderer::new(camera, options).map_err(error)?;
|
||||
let model = renderer.load_model(&mesh).map_err(error)?;
|
||||
let frames = shots
|
||||
.iter()
|
||||
.map(|shot| {
|
||||
let target = Target {
|
||||
model,
|
||||
pose: shot.target,
|
||||
};
|
||||
Ok((
|
||||
ephemeris.scene(shot.epoch, vec![target]).map_err(error)?,
|
||||
shot.camera,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let look = if cli.raw { Look::Raw } else { Look::Pretty };
|
||||
let preview = Preview::new(renderer.camera(), look);
|
||||
eprintln!(
|
||||
"{} frames on {}, set up in {:.1} s. The Moon is {:.0} degrees from the Sun; {:.0} \
|
||||
minutes of flight, cruising at {:.0}x real time, slowing for the moonrise at {} s \
|
||||
and the target's sunset at {} s.",
|
||||
frames.len(),
|
||||
renderer.adapter_info().name,
|
||||
started.elapsed().as_secs_f64(),
|
||||
plan.elongation.to_degrees(),
|
||||
plan.span / 60.0,
|
||||
plan.cruise,
|
||||
flight::MOONRISE_AT,
|
||||
flight::SUNSET_AT,
|
||||
);
|
||||
let detector = renderer.camera().detector;
|
||||
Ok(Self {
|
||||
renderer,
|
||||
preview,
|
||||
auto_exposure: (!cli.fixed_exposure).then_some(1.0 / cli.fps as f64),
|
||||
step: STEP.powf(1.0 / cli.fps as f64),
|
||||
detector,
|
||||
frames,
|
||||
})
|
||||
}
|
||||
|
||||
fn width(&self) -> u32 {
|
||||
self.renderer.camera().width
|
||||
}
|
||||
|
||||
fn height(&self) -> u32 {
|
||||
self.renderer.camera().height
|
||||
}
|
||||
|
||||
/// Frame `k` as 8-bit sRGB, and the next frame's exposure set from it.
|
||||
fn render(&mut self, k: usize) -> Result<Vec<u8>> {
|
||||
let (scene, camera) = &self.frames[k];
|
||||
let error = |e: resolved::Error| e.to_string();
|
||||
let view = self
|
||||
.renderer
|
||||
.submit(scene, camera)
|
||||
.map_err(error)?
|
||||
.wait_view()
|
||||
.map_err(error)?;
|
||||
let rgb = self.preview.srgb8(view.dn(), view.width);
|
||||
let level = brightest(view.dn(), view.channels as usize, &self.detector);
|
||||
drop(view);
|
||||
if let Some(longest) = self.auto_exposure {
|
||||
let exposure = self.renderer.camera().exposure;
|
||||
let wanted = exposure * LEVEL / level.max(1e-6);
|
||||
let next = wanted.clamp(exposure / self.step, exposure * self.step);
|
||||
self.renderer
|
||||
.set_exposure(next.clamp(SHORTEST, longest))
|
||||
.map_err(error)?;
|
||||
}
|
||||
Ok(rgb)
|
||||
}
|
||||
}
|
||||
|
||||
/// The 99.9th percentile of the image's brightest channel, as a fraction of full scale
|
||||
/// above the bias, from every fourth pixel.
|
||||
fn brightest(dn: &[u16], channels: usize, detector: &Detector) -> f64 {
|
||||
let (bias, saturated) = (detector.bias, detector.dn(detector.full_well) as f64);
|
||||
let mut counts = vec![0u32; 1 << detector.adc_bits];
|
||||
let last = counts.len() - 1;
|
||||
let mut total = 0u32;
|
||||
for pixel in dn.chunks(channels).step_by(4) {
|
||||
let top = pixel.iter().copied().max().unwrap_or(0) as usize;
|
||||
counts[top.min(last)] += 1;
|
||||
total += 1;
|
||||
}
|
||||
let mut above = total / 1000;
|
||||
for (value, &count) in counts.iter().enumerate().rev() {
|
||||
if count > above {
|
||||
return ((value as f64 - bias) / (saturated - bias)).max(0.0);
|
||||
}
|
||||
above -= count;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<()> {
|
||||
if cli.out.is_none() && cli.serve.is_none() {
|
||||
return Err("nothing to do: give --out FILE or --serve ADDRESS".into());
|
||||
}
|
||||
let mut movie = Movie::new(cli)?;
|
||||
if let Some(out) = &cli.out {
|
||||
record(&mut movie, out, cli.fps)?;
|
||||
}
|
||||
if let Some(address) = &cli.serve {
|
||||
serve(&mut movie, address, cli.fps)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every frame, through ffmpeg to H.264.
|
||||
fn record(movie: &mut Movie, out: &Path, fps: u32) -> Result<()> {
|
||||
let size = format!("{}x{}", movie.width(), movie.height());
|
||||
let mut ffmpeg = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
])
|
||||
.args(["-s", &size, "-r", &fps.to_string(), "-i", "-"])
|
||||
.args(["-c:v", "libx264", "-preset", "slow", "-crf", "18"])
|
||||
.args(["-pix_fmt", "yuv420p", "-movflags", "+faststart"])
|
||||
.arg(out)
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("ffmpeg: {e} (is it installed?)"))?;
|
||||
let mut input = ffmpeg.stdin.take().expect("piped");
|
||||
let started = Instant::now();
|
||||
let mut rendering = Duration::ZERO;
|
||||
let mut exposures = Vec::with_capacity(movie.frames.len());
|
||||
for k in 0..movie.frames.len() {
|
||||
let t = Instant::now();
|
||||
exposures.push(movie.renderer.camera().exposure);
|
||||
let rgb = movie.render(k)?;
|
||||
rendering += t.elapsed();
|
||||
input.write_all(&rgb).map_err(|e| format!("ffmpeg: {e}"))?;
|
||||
}
|
||||
drop(input);
|
||||
let status = ffmpeg.wait().map_err(|e| format!("ffmpeg: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("ffmpeg failed: {status}"));
|
||||
}
|
||||
let n = movie.frames.len() as f64;
|
||||
eprintln!(
|
||||
"wrote {}: rendering and tone mapping {:.1} ms per frame, {:.1} s in all with encoding",
|
||||
out.display(),
|
||||
rendering.as_secs_f64() * 1e3 / n,
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
// The exposure each second, for seeing what auto exposure did.
|
||||
let each: Vec<String> = exposures
|
||||
.iter()
|
||||
.step_by(fps as usize)
|
||||
.map(|e| format!("{:.2}", e * 1e3))
|
||||
.collect();
|
||||
eprintln!("exposure each second, ms: {}", each.join(" "));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Live, in step with the wall clock, looping, while anyone watches. JPEG encoding
|
||||
/// takes several times as long as rendering, so it runs on worker threads.
|
||||
fn serve(movie: &mut Movie, address: &str, fps: u32) -> Result<()> {
|
||||
const ENCODERS: usize = 8;
|
||||
let listener = std::net::TcpListener::bind(address).map_err(|e| format!("{address}: {e}"))?;
|
||||
let broadcast = Arc::new(stream::Broadcast::default());
|
||||
{
|
||||
let broadcast = broadcast.clone();
|
||||
std::thread::spawn(move || stream::serve(listener, broadcast));
|
||||
}
|
||||
let (width, height) = (movie.width(), movie.height());
|
||||
// Frames go to the encoders in turn, and one publisher takes the JPEGs back in the
|
||||
// same turn, so they reach viewers in order and none is dropped for finishing late.
|
||||
let mut encoders = Vec::with_capacity(ENCODERS);
|
||||
let mut finished = Vec::with_capacity(ENCODERS);
|
||||
for _ in 0..ENCODERS {
|
||||
let (to_encoder, from_renderer) = std::sync::mpsc::sync_channel::<(u64, Vec<u8>)>(1);
|
||||
let (to_publisher, from_encoder) = std::sync::mpsc::sync_channel::<(u64, Vec<u8>)>(1);
|
||||
std::thread::spawn(move || {
|
||||
for (sequence, rgb) in from_renderer {
|
||||
let mut jpeg = Vec::new();
|
||||
let encoded = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 85)
|
||||
.encode(&rgb, width, height, image::ExtendedColorType::Rgb8);
|
||||
if encoded.is_err() || to_publisher.send((sequence, jpeg)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
encoders.push(to_encoder);
|
||||
finished.push(from_encoder);
|
||||
}
|
||||
{
|
||||
let broadcast = broadcast.clone();
|
||||
std::thread::spawn(move || {
|
||||
for turn in (0..ENCODERS).cycle() {
|
||||
let Ok((sequence, jpeg)) = finished[turn].recv() else {
|
||||
return;
|
||||
};
|
||||
broadcast.publish(sequence, jpeg);
|
||||
}
|
||||
});
|
||||
}
|
||||
eprintln!("streaming at http://{address}/ (Ctrl+C to stop)");
|
||||
|
||||
let frame_time = Duration::from_secs_f64(1.0 / fps as f64);
|
||||
let count = movie.frames.len() as u64;
|
||||
let mut start = Instant::now();
|
||||
// `tick` counts frames of the flight and restarts for each new audience; `sequence`
|
||||
// orders frames for the encoders and never restarts.
|
||||
let (mut tick, mut sequence) = (0u64, 0u64);
|
||||
let mut watching = false;
|
||||
let (mut shown, mut skipped, mut rendering, mut report) =
|
||||
(0u32, 0u64, Duration::ZERO, Instant::now());
|
||||
loop {
|
||||
if broadcast.viewers() == 0 {
|
||||
watching = false;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
if !watching {
|
||||
// Start the flight over for a new audience.
|
||||
(watching, start, tick) = (true, Instant::now(), 0);
|
||||
}
|
||||
if let Some(wait) = (frame_time * tick as u32).checked_sub(start.elapsed()) {
|
||||
std::thread::sleep(wait);
|
||||
}
|
||||
// More than a frame late: skip ahead to the frame that is due.
|
||||
let due = (start.elapsed().as_secs_f64() * fps as f64) as u64;
|
||||
if due > tick + 1 {
|
||||
skipped += due - tick;
|
||||
tick = due;
|
||||
}
|
||||
let t = Instant::now();
|
||||
let rgb = movie.render((tick % count) as usize)?;
|
||||
rendering += t.elapsed();
|
||||
encoders[(sequence % ENCODERS as u64) as usize]
|
||||
.send((sequence, rgb))
|
||||
.map_err(|_| "the encoders stopped".to_string())?;
|
||||
(tick, sequence) = (tick + 1, sequence + 1);
|
||||
shown += 1;
|
||||
if report.elapsed() >= Duration::from_secs(5) {
|
||||
eprintln!(
|
||||
"{:.1} frames per second ({} skipped), rendering {:.1} ms per frame",
|
||||
shown as f64 / report.elapsed().as_secs_f64(),
|
||||
skipped,
|
||||
rendering.as_secs_f64() * 1e3 / shown as f64
|
||||
);
|
||||
(shown, skipped, rendering, report) = (0, 0, Duration::ZERO, Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! A live MJPEG stream over HTTP: each frame a JPEG, sent to every viewer as one part
|
||||
//! of a `multipart/x-mixed-replace` response, which browsers show as a moving image in
|
||||
//! an `<img>`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// The newest frame, shared by the encoders and every viewer.
|
||||
#[derive(Default)]
|
||||
pub struct Broadcast {
|
||||
/// A count that rises with each frame published, the frame's own sequence number,
|
||||
/// and the JPEG.
|
||||
latest: Mutex<(u64, u64, Arc<Vec<u8>>)>,
|
||||
fresh: Condvar,
|
||||
viewers: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Broadcast {
|
||||
/// Publishes frame `sequence` to every viewer, unless a later frame is already out.
|
||||
pub fn publish(&self, sequence: u64, jpeg: Vec<u8>) {
|
||||
let mut latest = self.latest.lock().expect("not poisoned");
|
||||
if latest.0 > 0 && sequence <= latest.1 {
|
||||
return;
|
||||
}
|
||||
*latest = (latest.0 + 1, sequence, Arc::new(jpeg));
|
||||
self.fresh.notify_all();
|
||||
}
|
||||
|
||||
/// How many viewers are connected.
|
||||
pub fn viewers(&self) -> usize {
|
||||
self.viewers.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Waits for a frame newer than `seen`.
|
||||
fn next(&self, seen: u64) -> (u64, Arc<Vec<u8>>) {
|
||||
let mut latest = self.latest.lock().expect("not poisoned");
|
||||
while latest.0 == seen {
|
||||
latest = self.fresh.wait(latest).expect("not poisoned");
|
||||
}
|
||||
(latest.0, latest.2.clone())
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>resolved movie</title>\
|
||||
<style>body{margin:0;background:#000;display:grid;place-items:center;height:100vh}\
|
||||
img{max-width:100%;max-height:100vh}</style></head><body><img src=\"/stream\" alt=\"live\"></body></html>";
|
||||
|
||||
/// Serves `broadcast` on `listener` until the process ends: the stream at `/stream`
|
||||
/// and a page showing it at `/`.
|
||||
pub fn serve(listener: TcpListener, broadcast: Arc<Broadcast>) {
|
||||
for connection in listener.incoming() {
|
||||
let Ok(connection) = connection else { continue };
|
||||
let broadcast = broadcast.clone();
|
||||
std::thread::spawn(move || {
|
||||
// A viewer that hangs up ends its write with an error; nothing to report.
|
||||
let _ = handle(connection, &broadcast);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(mut connection: TcpStream, broadcast: &Broadcast) -> std::io::Result<()> {
|
||||
let mut request = String::new();
|
||||
BufReader::new(&connection).read_line(&mut request)?;
|
||||
let path = request.split_whitespace().nth(1).unwrap_or("/");
|
||||
match path {
|
||||
"/" => {
|
||||
write!(
|
||||
connection,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{PAGE}",
|
||||
PAGE.len()
|
||||
)
|
||||
}
|
||||
"/stream" => {
|
||||
write!(
|
||||
connection,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=frame\r\n\
|
||||
Cache-Control: no-cache\r\nConnection: close\r\n\r\n"
|
||||
)?;
|
||||
broadcast.viewers.fetch_add(1, Ordering::Relaxed);
|
||||
let result = (|| {
|
||||
let mut seen = 0;
|
||||
loop {
|
||||
let (seq, jpeg) = broadcast.next(seen);
|
||||
seen = seq;
|
||||
write!(
|
||||
connection,
|
||||
"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
|
||||
jpeg.len()
|
||||
)?;
|
||||
connection.write_all(&jpeg)?;
|
||||
connection.write_all(b"\r\n")?;
|
||||
}
|
||||
})();
|
||||
broadcast.viewers.fetch_sub(1, Ordering::Relaxed);
|
||||
result
|
||||
}
|
||||
_ => write!(
|
||||
connection,
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub mod fidelity;
|
||||
pub mod gpu;
|
||||
pub mod model;
|
||||
pub mod moon;
|
||||
pub mod preview;
|
||||
pub mod radiometry;
|
||||
pub mod render;
|
||||
pub mod scene;
|
||||
@@ -42,6 +43,7 @@ pub use error::Error;
|
||||
pub use fidelity::{CameraTier, Fidelity, LightingTier};
|
||||
pub use model::{LoadOptions, Material, Mesh, ModelId};
|
||||
pub use moon::MoonTexture;
|
||||
pub use preview::{Look, Preview};
|
||||
pub use radiometry::Radiometry;
|
||||
pub use render::{Frame, FrameView, Pending, Renderer, RendererOptions};
|
||||
pub use scene::{CameraRelative, Placement, Pose, Scene, Target};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Turning a frame's DN into an 8-bit sRGB image for looking at.
|
||||
//!
|
||||
//! [`Look::Raw`] shows the signal as recorded: linear from the bias (black) to
|
||||
//! saturation (white), with the sRGB curve on top. A scene exposed to keep its
|
||||
//! brightest parts unsaturated looks dark in it. [`Look::Pretty`] makes the image look
|
||||
//! like a photograph instead: each channel scaled so a white surface in sunlight comes
|
||||
//! out neutral, then a tone curve ([`tone`]) that brightens shadows and midtones and
|
||||
//! rolls highlights off to white at saturation. Neither changes the DN.
|
||||
|
||||
use crate::camera::CameraConfig;
|
||||
use crate::earth::parallel_rows;
|
||||
use crate::radiometry::{Radiometry, AU};
|
||||
|
||||
/// How DN become an image.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Look {
|
||||
/// The signal as recorded.
|
||||
#[default]
|
||||
Raw,
|
||||
/// White balanced to sunlight, with [`tone`].
|
||||
Pretty,
|
||||
}
|
||||
|
||||
/// How much [`tone`] brightens the darkest values: 3, about 1.6 stops.
|
||||
pub const PRETTY_LIFT: f64 = 3.0;
|
||||
|
||||
/// The pretty tone curve, from the fraction of saturation `x` to linear display value:
|
||||
/// Reinhard's operator with its white point at saturation. It starts with slope
|
||||
/// [`PRETTY_LIFT`] and rises ever more slowly to 1 at `x = 1`, so midtones brighten
|
||||
/// and highlights compress instead of clipping.
|
||||
pub fn tone(x: f64) -> f64 {
|
||||
let x = x.max(0.0);
|
||||
PRETTY_LIFT * x * (1.0 + x / PRETTY_LIFT) / (1.0 + PRETTY_LIFT * x)
|
||||
}
|
||||
|
||||
fn srgb(x: f64) -> u8 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
let v = if x <= 0.003_130_8 {
|
||||
12.92 * x
|
||||
} else {
|
||||
1.055 * x.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(v * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// Converts one camera's DN to 8-bit sRGB, through a table with an entry for every DN
|
||||
/// in every channel.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Preview {
|
||||
channels: usize,
|
||||
/// The DN of a saturated pixel.
|
||||
saturated: u16,
|
||||
look: Look,
|
||||
/// Per channel, the output for each DN.
|
||||
table: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Preview {
|
||||
/// The conversion for `camera`'s detector and channels.
|
||||
pub fn new(camera: &CameraConfig, look: Look) -> Self {
|
||||
let detector = &camera.detector;
|
||||
let channels = camera.channels.len();
|
||||
let black = detector.bias;
|
||||
let saturated = detector.dn(detector.full_well);
|
||||
let white = saturated as f64;
|
||||
// Each channel scaled to the weakest one's response to a white surface in
|
||||
// sunlight, so sunlit white comes out neutral and no channel is amplified.
|
||||
let balance: Vec<f64> = match look {
|
||||
Look::Raw => vec![1.0; channels],
|
||||
Look::Pretty => {
|
||||
let radiometry = Radiometry::new(camera);
|
||||
let white_surface: Vec<f64> = (0..channels)
|
||||
.map(|k| radiometry.lambert_radiance(k, 1.0, 1.0, AU))
|
||||
.collect();
|
||||
let weakest = white_surface.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
white_surface.iter().map(|w| weakest / w).collect()
|
||||
}
|
||||
};
|
||||
let levels = 1usize << detector.adc_bits;
|
||||
let table = balance
|
||||
.iter()
|
||||
.map(|b| {
|
||||
(0..levels)
|
||||
.map(|d| {
|
||||
let x = (d as f64 - black) / (white - black);
|
||||
match look {
|
||||
Look::Raw => srgb(x),
|
||||
Look::Pretty => srgb(tone(x * b)),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
channels,
|
||||
saturated,
|
||||
look,
|
||||
table,
|
||||
}
|
||||
}
|
||||
|
||||
/// The image for `dn` (channels interleaved per pixel, rows of `width` pixels from
|
||||
/// the top, as in [`crate::Frame::dn`]), in the same layout.
|
||||
pub fn srgb8(&self, dn: &[u16], width: u32) -> Vec<u8> {
|
||||
let row = width as usize * self.channels;
|
||||
let mut out = vec![0u8; dn.len()];
|
||||
parallel_rows(&mut out, row, |y, out_row| {
|
||||
let in_row = &dn[y * row..(y + 1) * row];
|
||||
for (pixel, out_pixel) in in_row
|
||||
.chunks(self.channels)
|
||||
.zip(out_row.chunks_mut(self.channels))
|
||||
{
|
||||
// In the pretty look, a channel at saturation lost how bright it was,
|
||||
// and balancing the others would tint it: saturated pixels are white.
|
||||
if self.look == Look::Pretty && pixel.iter().any(|&d| d >= self.saturated) {
|
||||
out_pixel.fill(255);
|
||||
continue;
|
||||
}
|
||||
for ((o, &d), table) in out_pixel.iter_mut().zip(pixel).zip(&self.table) {
|
||||
*o = table[(d as usize).min(table.len() - 1)];
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tone_lifts_the_darks_and_reaches_white_at_saturation() {
|
||||
assert_eq!(tone(0.0), 0.0);
|
||||
assert!((tone(1.0) - 1.0).abs() < 1e-12);
|
||||
// Slope PRETTY_LIFT at zero; brighter than raw everywhere between.
|
||||
assert!((tone(1e-6) / 1e-6 - PRETTY_LIFT).abs() < 1e-3);
|
||||
let mut last = 0.0;
|
||||
for i in 1..100 {
|
||||
let x = i as f64 / 100.0;
|
||||
assert!(tone(x) > x && tone(x) > last);
|
||||
last = tone(x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_maps_bias_to_black_and_saturation_to_white_and_pretty_whitens_saturation() {
|
||||
let camera = CameraConfig::example();
|
||||
let saturated = camera.detector.dn(camera.detector.full_well);
|
||||
let bias = camera.detector.bias as u16;
|
||||
let raw = Preview::new(&camera, Look::Raw);
|
||||
assert_eq!(
|
||||
raw.srgb8(&[bias, bias, bias, saturated, saturated, saturated], 2),
|
||||
[0, 0, 0, 255, 255, 255]
|
||||
);
|
||||
// One saturated channel: white in the pretty look, whatever the others hold.
|
||||
let pretty = Preview::new(&camera, Look::Pretty);
|
||||
assert_eq!(pretty.srgb8(&[saturated, bias, bias], 1), [255, 255, 255]);
|
||||
// Below saturation, pretty is brighter than raw.
|
||||
let mid = bias + 300;
|
||||
let (r, p) = (raw.srgb8(&[mid; 3], 1), pretty.srgb8(&[mid; 3], 1));
|
||||
assert!((0..3).all(|k| p[k] > r[k]), "{r:?} {p:?}");
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,21 @@ impl Renderer {
|
||||
self.body_radiometry.moon_textured = true;
|
||||
}
|
||||
|
||||
/// Changes the exposure time, in seconds, from the next frame submitted on: what an
|
||||
/// auto-exposure loop does between frames. Only the conversion from radiance to
|
||||
/// electrons depends on it, so this costs nothing beyond a few multiplications.
|
||||
pub fn set_exposure(&mut self, seconds: f64) -> Result<(), Error> {
|
||||
if !(seconds.is_finite() && seconds > 0.0) {
|
||||
return Err(Error::InvalidCamera(format!(
|
||||
"exposure must be positive, not {seconds}"
|
||||
)));
|
||||
}
|
||||
self.radiometry.electrons_per_radiance *= seconds / self.camera.exposure;
|
||||
self.camera.exposure = seconds;
|
||||
self.sensor_uniforms = SensorUniforms::new(&self.camera, &self.radiometry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tiers this renderer draws at.
|
||||
pub fn fidelity(&self) -> Fidelity {
|
||||
self.fidelity
|
||||
|
||||
@@ -322,6 +322,50 @@ fn the_sun_is_uniform_at_l0_and_limb_darkened_at_l1() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_exposure_scales_the_signal() {
|
||||
// Doubling the exposure doubles every unsaturated pixel's signal above the bias,
|
||||
// to within the rounding of one DN; an invalid exposure is refused and changes
|
||||
// nothing.
|
||||
let _devices = common::device_lock();
|
||||
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
|
||||
let options = RendererOptions {
|
||||
adapter,
|
||||
..Default::default()
|
||||
};
|
||||
let mut renderer = Renderer::new(CameraConfig::example(), options).unwrap();
|
||||
let model = renderer.load_model(&cube()).unwrap();
|
||||
let scene = scene(vec![cube_target(model)]);
|
||||
let once = renderer_frame(&mut renderer, &scene);
|
||||
let bias = CameraConfig::example().detector.bias as i32;
|
||||
renderer
|
||||
.set_exposure(2.0 * CameraConfig::example().exposure)
|
||||
.unwrap();
|
||||
assert!(renderer.set_exposure(0.0).is_err());
|
||||
assert!(renderer.set_exposure(f64::NAN).is_err());
|
||||
let twice = renderer_frame(&mut renderer, &scene);
|
||||
let name = renderer.adapter_info().name.clone();
|
||||
let mut lit = 0;
|
||||
for (&a, &b) in once.iter().zip(&twice) {
|
||||
let (a, b) = (a as i32 - bias, b as i32 - bias);
|
||||
if a > 100 && a < 1500 {
|
||||
lit += 1;
|
||||
assert!((b - 2 * a).abs() <= 2, "{name}: {a} then {b}");
|
||||
}
|
||||
}
|
||||
assert!(lit > 1000, "{name}: {lit} lit samples");
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_frame(renderer: &mut Renderer, scene: &Scene) -> Vec<u16> {
|
||||
renderer
|
||||
.submit(scene, &observer())
|
||||
.unwrap()
|
||||
.wait()
|
||||
.unwrap()
|
||||
.dn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_view_reads_what_wait_copies() {
|
||||
// Both ways of getting the image to the CPU, read where the sensor pass writes it
|
||||
|
||||
+9
-4
@@ -4,19 +4,24 @@
|
||||
|
||||
set -e
|
||||
|
||||
# resolved-showcase (the showcase movie) is left out of every step here; pre-commit's
|
||||
# clippy still builds it. It also turns on the library's astrokit feature, and Cargo
|
||||
# unifies features across a workspace build, so with it in, the steps without default
|
||||
# features would build the library with astrokit after all.
|
||||
|
||||
echo "pre-push: cargo test"
|
||||
cargo test --workspace --all-features --quiet
|
||||
cargo test --workspace --exclude resolved-showcase --all-features --quiet
|
||||
|
||||
# Without default features: every target must still build cleanly, and the unit
|
||||
# tests run. The GPU integration tests do not depend on features, so the run above
|
||||
# covers them.
|
||||
echo "pre-push: cargo clippy --no-default-features"
|
||||
cargo clippy --workspace --all-targets --no-default-features --quiet -- -D warnings
|
||||
cargo clippy --workspace --exclude resolved-showcase --all-targets --no-default-features --quiet -- -D warnings
|
||||
|
||||
echo "pre-push: cargo test --no-default-features (unit tests)"
|
||||
cargo test --workspace --no-default-features --lib --bins --quiet
|
||||
cargo test --workspace --exclude resolved-showcase --no-default-features --lib --bins --quiet
|
||||
|
||||
echo "pre-push: cargo doc"
|
||||
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --quiet
|
||||
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --exclude resolved-showcase --no-deps --all-features --quiet
|
||||
|
||||
echo "pre-push: ok"
|
||||
@@ -131,6 +131,23 @@ cargo run --release -p resolved-cli -- render \
|
||||
`scripts/serve-gallery.py` serves the result as a web page on port 8000 of
|
||||
every interface, each scene captioned from its file.
|
||||
|
||||
`crates/resolved-showcase` makes a 30-second movie at lighting L1: an inspector
|
||||
holding station behind a Landsat 8 model 705 km up, which astrokit propagates
|
||||
with J2. Time runs fast over the daylit Earth, slows as the Moon rises over the
|
||||
limb ahead, and slows almost to real time as the spacecraft passes into Earth's
|
||||
shadow. The camera drifts on a small natural relative orbit around its station,
|
||||
and adjusts its exposure frame by frame as a video camera does (up to one
|
||||
frame's time), through dawn and dusk. `--out movie.mp4` encodes it with ffmpeg,
|
||||
rendering each 1920 x 1080 frame in about 4 ms on the RTX 3060. `--serve
|
||||
0.0.0.0:8010` renders it live, in step with the clock, and streams it as MJPEG
|
||||
at 60 frames per second (`--fps`; it keeps up to 120, rendering each frame in
|
||||
under 5 ms) to the gallery page's Movie tab, which also plays and offers the
|
||||
recorded file (`make-gallery.sh` writes `gallery/movie.mp4`):
|
||||
|
||||
```sh
|
||||
cargo run --release -p resolved-showcase -- --serve 0.0.0.0:8010
|
||||
```
|
||||
|
||||
- `--out` writes raw DN as a 16-bit PNG, shifted up to fill 16 bits as
|
||||
cameras usually store them: a 12-bit DN is the stored value divided by 16.
|
||||
It is linear, so it looks dark next to the preview.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Renders every example scene at lighting L0 and L1 into gallery/ (gitignored), as
|
||||
# <scene>-L0.png and <scene>-L1.png previews made with --pretty: white balanced, with a
|
||||
# tone curve, from exposures that keep the raw signal short of saturation. A scene whose
|
||||
# model, maps or kernels are missing is skipped with the reason. View the result with
|
||||
# model, maps or kernels are missing is skipped with the reason. Then the showcase
|
||||
# movie, gallery/movie.mp4 (resolved-movie, which needs ffmpeg). View the result with
|
||||
# scripts/serve-gallery.py.
|
||||
#
|
||||
# Needs: scripts/fetch-models.sh for the NASA models, scripts/fetch-earth.sh and
|
||||
@@ -11,7 +12,7 @@
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
cargo build --quiet --release -p resolved-cli
|
||||
cargo build --quiet --release -p resolved-cli -p resolved-showcase
|
||||
mkdir -p gallery
|
||||
|
||||
made=0
|
||||
@@ -32,3 +33,9 @@ for scene in examples/scenes/*.toml; do
|
||||
done
|
||||
done
|
||||
echo "$made images in gallery/, $skipped skipped"
|
||||
|
||||
if error=$(./target/release/resolved-movie --out gallery/movie.mp4 2>&1); then
|
||||
echo "gallery/movie.mp4"
|
||||
else
|
||||
echo "skipped the movie: ${error#error: }"
|
||||
fi
|
||||
+105
-9
@@ -2,13 +2,18 @@
|
||||
"""Serves gallery/ (from scripts/make-gallery.sh) as a web page on every interface, for
|
||||
looking at the example renders from another machine on the network.
|
||||
|
||||
python3 scripts/serve-gallery.py [--port 8000]
|
||||
python3 scripts/serve-gallery.py [--port 8000] [--live-port 8010]
|
||||
|
||||
Each example scene gets a card with its L0 and L1 renders side by side, captioned
|
||||
from the comment at the top of its file in examples/scenes/. Click an image to see it
|
||||
full size; arrow keys step through them and Escape closes. The page is built on each
|
||||
request, so rerunning make-gallery.sh and reloading shows the new images. Only the
|
||||
PNG files in gallery/ are served.
|
||||
Stills: each example scene gets a card with its L0 and L1 renders side by side,
|
||||
captioned from the comment at the top of its file in examples/scenes/. Click an image
|
||||
to see it full size; arrow keys step through them and Escape closes.
|
||||
|
||||
Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play or
|
||||
download, and the live stream from `resolved-movie --serve 0.0.0.0:8010` on this
|
||||
machine, which the page connects to only while the tab is open.
|
||||
|
||||
The page is built on each request, so rerunning make-gallery.sh and reloading shows
|
||||
the new images. Only the PNG files and movie.mp4 in gallery/ are served.
|
||||
|
||||
Needs only the Python standard library.
|
||||
"""
|
||||
@@ -75,9 +80,37 @@ figcaption b { color: var(--text); font-weight: 600; }
|
||||
#view.open { display: flex; }
|
||||
#view img { max-width: 100%; max-height: calc(100vh - 4rem); }
|
||||
#view div { color: var(--muted); font-size: 0.9rem; }
|
||||
[hidden] { display: none !important; }
|
||||
nav { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 0.5rem; }
|
||||
nav a {
|
||||
color: var(--muted); text-decoration: none; padding: 0.45rem 1rem; border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
nav a.on { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
||||
.movie video, .movie .live {
|
||||
width: 100%; aspect-ratio: 16 / 9; display: block; background: #000;
|
||||
border-radius: 8px; border: 1px solid var(--line);
|
||||
}
|
||||
.movie .live { object-fit: contain; }
|
||||
.movie a.download { color: var(--accent); }
|
||||
.movie code { color: var(--text); }
|
||||
"""
|
||||
|
||||
SCRIPT = """
|
||||
const live = document.getElementById('live');
|
||||
function tab() {
|
||||
const movie = location.hash === '#movie';
|
||||
// The panels' ids differ from the links' anchors, so a link switches the tab
|
||||
// instead of scrolling the page to it.
|
||||
document.getElementById('stills-tab').hidden = movie;
|
||||
document.getElementById('movie-tab').hidden = !movie;
|
||||
document.querySelectorAll('nav a').forEach(
|
||||
(a) => a.classList.toggle('on', (a.hash === '#movie') === movie));
|
||||
// The live stream renders only while someone watches: connect only on this tab.
|
||||
if (live) live.src = movie ? live.dataset.src : '';
|
||||
}
|
||||
window.addEventListener('hashchange', tab);
|
||||
tab();
|
||||
const images = [...document.querySelectorAll('figure img')];
|
||||
const view = document.getElementById('view');
|
||||
const big = view.querySelector('img');
|
||||
@@ -120,7 +153,40 @@ def caption(scene):
|
||||
return paragraphs
|
||||
|
||||
|
||||
def page():
|
||||
def movie_section(host, live_port):
|
||||
movie = GALLERY / "movie.mp4"
|
||||
if movie.is_file():
|
||||
stamp = int(movie.stat().st_mtime)
|
||||
size = movie.stat().st_size / 1e6
|
||||
recorded = (
|
||||
f'<video src="/movie.mp4?{stamp}" controls loop playsinline></video>'
|
||||
f'<p class="caption"><a class="download" href="/movie.mp4?{stamp}" '
|
||||
f'download="resolved-movie.mp4">Download the MP4</a> ({size:.1f} MB)</p>'
|
||||
)
|
||||
else:
|
||||
recorded = (
|
||||
'<p class="caption">No movie yet: run scripts/make-gallery.sh, or '
|
||||
"<code>cargo run --release -p resolved-showcase -- --out gallery/movie.mp4</code>"
|
||||
".</p>"
|
||||
)
|
||||
stream = f"http://{host}:{live_port}/stream"
|
||||
return f"""<section class="movie"><h2>The movie</h2>
|
||||
<p class="caption">An inspector holds station 120 m behind and above a Landsat 8 model
|
||||
705 km up; the target is propagated by astrokit with J2 and everything is rendered at
|
||||
lighting L1. The camera drifts on a small natural relative orbit around its station and
|
||||
sets its exposure frame by frame. Time runs fast over the daylit Earth, slows as the
|
||||
Moon rises over the limb ahead, and slows almost to real time as the spacecraft passes
|
||||
into Earth's shadow.</p>{recorded}</section>
|
||||
<section class="movie"><h2>Live</h2>
|
||||
<p class="caption">Rendered in real time on this machine's GPU and streamed as it is
|
||||
made. Start it with
|
||||
<code>cargo run --release -p resolved-showcase -- --serve 0.0.0.0:{live_port}</code>;
|
||||
it renders only while this tab is open.</p>
|
||||
<img id="live" class="live" data-src="{html.escape(stream)}" alt="live stream (not running)">
|
||||
</section>"""
|
||||
|
||||
|
||||
def page(host, live_port):
|
||||
names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")})
|
||||
cards = []
|
||||
count = 0
|
||||
@@ -168,7 +234,9 @@ def page():
|
||||
<body><header><h1><span>resolved</span> gallery</h1>
|
||||
<p>{count} renders of {len(names)} example scenes, last rendered {when}.
|
||||
Click an image to see it full size.</p></header>
|
||||
<main>{"".join(cards)}</main>
|
||||
<nav><a href="#stills">Stills</a><a href="#movie">Movie</a></nav>
|
||||
<main id="stills-tab">{"".join(cards)}</main>
|
||||
<main id="movie-tab" hidden>{movie_section(host, live_port)}</main>
|
||||
<div id="view"><img alt=""><div></div></div>
|
||||
<script>{SCRIPT}</script></body></html>"""
|
||||
|
||||
@@ -177,7 +245,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
path = unquote(self.path.split("?", 1)[0])
|
||||
if path in ("/", "/index.html"):
|
||||
self.send(200, "text/html; charset=utf-8", page().encode())
|
||||
host = (self.headers.get("Host") or "localhost").rsplit(":", 1)[0]
|
||||
body = page(host, self.server.live_port).encode()
|
||||
self.send(200, "text/html; charset=utf-8", body)
|
||||
elif path == "/movie.mp4" and (GALLERY / "movie.mp4").is_file():
|
||||
self.send_movie(GALLERY / "movie.mp4")
|
||||
elif path.startswith("/img/"):
|
||||
name = path[len("/img/"):]
|
||||
# Only a PNG directly in gallery/, by its exact name.
|
||||
@@ -189,6 +261,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send(404, "text/plain", b"not found")
|
||||
|
||||
def send_movie(self, movie):
|
||||
"""The movie, or the byte range asked for: browsers seek in a video by range."""
|
||||
data = movie.read_bytes()
|
||||
wanted = self.headers.get("Range", "")
|
||||
if wanted.startswith("bytes="):
|
||||
first, _, last = wanted[len("bytes="):].partition("-")
|
||||
start = int(first) if first else max(len(data) - int(last), 0)
|
||||
end = int(last) if first and last else len(data) - 1
|
||||
end = min(end, len(data) - 1)
|
||||
part = data[start:end + 1]
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
|
||||
else:
|
||||
part = data
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Length", str(len(part)))
|
||||
self.end_headers()
|
||||
self.wfile.write(part)
|
||||
|
||||
def send(self, status, kind, body):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", kind)
|
||||
@@ -215,8 +308,11 @@ def lan_address():
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
parser.add_argument("--live-port", type=int, default=8010,
|
||||
help="the port resolved-movie --serve streams on")
|
||||
args = parser.parse_args()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
|
||||
server.live_port = args.live_port
|
||||
print(f"serving {GALLERY} at http://{lan_address()}:{args.port}/ (Ctrl+C to stop)", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
|
||||
Reference in new issue
Block a user