use anyhow::{Context, Result, bail};
use clap::{Args, ValueEnum};
use image::{Rgb, Rgb32FImage};
use seiza::{DetectConfig, Wcs};
use seiza_parallax::{
CustomLabel, Easing, Event, FfmpegSink, FrameSink, LightImage, Parallax, ParallaxOptions,
PngSequence, Quality, Scene, SceneOptions, SmallStars, Start, VideoOptions, VideoSettings,
};
use std::path::{Path, PathBuf};
#[derive(Args)]
pub(crate) struct ParallaxVideoArgs {
image: Option<PathBuf>,
#[arg(long, requires = "stars")]
starless: Option<PathBuf>,
#[arg(long, requires = "starless")]
stars: Option<PathBuf>,
#[arg(short, long, required_unless_present_any = ["plan_tour", "export_dust_map"])]
output: Option<PathBuf>,
#[arg(long, value_parser = parse_point)]
focus: Option<(f64, f64)>,
#[arg(long)]
distance: Option<f64>,
#[arg(long, value_parser = parse_point)]
distance_focus: Option<(f64, f64)>,
#[arg(long)]
objects: Option<PathBuf>,
#[arg(long)]
distances: Option<PathBuf>,
#[arg(long)]
unmatched_distance: Option<f64>,
#[arg(long, default_value_t = 0.4)]
dolly: f64,
#[arg(long, default_value_t = 0.0)]
truck: f64,
#[arg(long, default_value_t = 1.0)]
zoom_end: f64,
#[arg(long, default_value_t = 0.0)]
pan: f64,
#[arg(long, default_value_t = 0.0)]
truck_angle: f64,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
rotate: f64,
#[arg(long, allow_hyphen_values = true)]
rotate_end: Option<f64>,
#[arg(long, value_enum, default_value_t = StartArg::Focus)]
start: StartArg,
#[arg(long, default_value_t = 1.0)]
zoom: f64,
#[arg(long, default_value_t = 8.0)]
seconds: f64,
#[arg(long = "stop", value_parser = seiza_parallax::parse_stop, allow_hyphen_values = true)]
stops: Vec<seiza_parallax::TourStop>,
#[arg(
long,
num_args = 0..=1,
require_equals = true,
default_missing_value = "0",
conflicts_with = "stops"
)]
auto_tour: Option<usize>,
#[arg(
long,
conflicts_with_all = ["stops", "tour_file", "output", "tour_loop", "tour_titles", "debug_layers"]
)]
plan_tour: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["stops", "auto_tour"])]
tour_file: Option<PathBuf>,
#[arg(long, default_value_t = 1.5)]
tour_hold: f64,
#[arg(long, default_value_t = 1.0)]
tour_motion: f64,
#[arg(long, default_value_t = 0.2)]
tour_glide: f64,
#[arg(long)]
tour_titles: bool,
#[arg(long = "loop")]
tour_loop: bool,
#[arg(long, default_value_t = 30)]
fps: u32,
#[arg(long, default_value = "1080p", value_parser = seiza_parallax::parse_frame_size)]
size: (usize, usize),
#[arg(long, value_enum, default_value_t = EasingArg::InOut)]
easing: EasingArg,
#[arg(long, value_enum, default_value_t = QualityArg::Standard)]
quality: QualityArg,
#[arg(long, default_value_t = 4.0)]
growth_limit: f64,
#[arg(long, default_value_t = 6.0)]
fade_from: f64,
#[arg(long)]
max_stars: Option<usize>,
#[arg(long, value_enum, default_value_t = SmallStarsArg::Drop)]
small_stars: SmallStarsArg,
#[arg(long, value_enum, default_value_t = EncoderArg::Auto)]
encoder: EncoderArg,
#[arg(long, default_value = "h264", value_parser = seiza_parallax::Codec::parse)]
codec: seiza_parallax::Codec,
#[arg(long, default_value = "ffmpeg")]
ffmpeg: PathBuf,
#[arg(long, default_value_t = 16.0)]
gaia_max_mag: f32,
#[arg(long)]
gaia_cache: Option<PathBuf>,
#[arg(long)]
star_distances: Option<PathBuf>,
#[arg(long)]
data: Option<PathBuf>,
#[arg(long)]
index: Option<PathBuf>,
#[arg(long)]
min_scale: Option<f64>,
#[arg(long)]
max_scale: Option<f64>,
#[arg(long, conflicts_with = "starless")]
keep_split: bool,
#[arg(long)]
debug_layers: Option<PathBuf>,
#[arg(long)]
keep_galaxies: bool,
#[arg(long)]
no_dust: bool,
#[arg(long, default_value_t = 3.0)]
dust_opacity: f32,
#[arg(long, conflicts_with = "no_dust")]
dust_map: Option<PathBuf>,
#[arg(long, conflicts_with = "no_dust")]
export_dust_map: Option<PathBuf>,
#[arg(long)]
overlay: bool,
#[arg(long, default_value_t = seiza_parallax::overlay::DEFAULT_DENSITY)]
overlay_density: f64,
#[arg(long = "label", value_parser = seiza_parallax::parse_label)]
labels: Vec<CustomLabel>,
#[arg(long, default_value = "#f0f4f8", value_parser = seiza_parallax::parse_color)]
label_color: Rgb<u8>,
#[arg(
long,
num_args = 0..=1,
require_equals = true,
default_missing_value = seiza_parallax::DEFAULT_WATERMARK
)]
watermark: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum QualityArg {
Standard,
High,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum StartArg {
Focus,
Whole,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum SmallStarsArg {
Drop,
Field,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum EasingArg {
Linear,
InOut,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum EncoderArg {
Auto,
Ffmpeg,
Openh264,
Png,
}
fn parse_point(text: &str) -> std::result::Result<(f64, f64), String> {
let (x, y) = text
.split_once(',')
.ok_or_else(|| format!("expected x,y; got {text}"))?;
let number = |part: &str| {
part.trim()
.parse::<f64>()
.map_err(|error| format!("{part}: {error}"))
};
Ok((number(x)?, number(y)?))
}
fn open_display(path: &Path) -> Result<Rgb32FImage> {
Ok(seiza_image_io::open(path)?.to_rgb())
}
pub(crate) fn run(args: ParallaxVideoArgs) -> Result<()> {
if args.image.is_none() && args.starless.is_none() {
bail!("give an image to split, or --starless and --stars");
}
if let Some(plan) = &args.plan_tour {
return write_plan(&args, plan);
}
let file = match &args.tour_file {
Some(path) => Some(read_tour(path)?),
None => None,
};
let mut options = options(&args, file);
if let Some(path) = &args.dust_map {
options.scene.dust_map =
Some(seiza_parallax::DustMap::open(path).map_err(anyhow::Error::msg)?);
}
options.check()?;
if args.output.is_some() {
check_output(&args)?;
}
if let Some(path) = &args.export_dust_map {
check_dust_export(&args, path)?;
}
crate::interrupt::install();
let stop = crate::interrupt::interrupted;
let started = std::time::Instant::now();
let (starless, stars, solve_path) = split(&args)?;
let (wcs, solved) = solve(&args, &solve_path)?;
if solved != starless.dimensions() {
bail!(
"{} is {}x{} but the split images are {}x{}: the plate solution must come from the \
same pixels",
solve_path.display(),
solved.0,
solved.1,
starless.width(),
starless.height()
);
}
let parallax = Parallax::prepare(&starless, &stars, &wcs, &options, &mut print_event, &stop)?;
if let Some(directory) = &args.debug_layers {
write_layers(directory, parallax.scene())?;
}
if let Some(path) = &args.export_dust_map {
let Some(dust) = parallax.dust() else {
bail!("too few stars show to map the dust; give one with --dust-map to export it");
};
let (width, height) = dust.size();
dust.to_map(width, height)
.save(path)
.map_err(anyhow::Error::msg)?;
println!("wrote the dust map to {}", path.display());
}
if args.output.is_none() {
return Ok(());
}
let sink = open_sink(&args, parallax.video_settings())?;
let rendering = std::time::Instant::now();
parallax.render(sink, &mut print_event, &stop)?;
println!(
"wrote {} ({} frames in {:.1}s, {:.1}s in all)",
output(&args).display(),
parallax.frames(),
rendering.elapsed().as_secs_f64(),
started.elapsed().as_secs_f64()
);
Ok(())
}
fn check_output(args: &ParallaxVideoArgs) -> Result<()> {
let output = output(args);
let same = |input: &Path| {
let canonical = |path: &Path| std::fs::canonicalize(path).ok();
canonical(input).is_some_and(|input| Some(input) == canonical(output))
};
for input in [&args.image, &args.starless, &args.stars]
.into_iter()
.flatten()
{
if same(input) {
bail!(
"-o {} is an input; the video would overwrite it",
output.display()
);
}
}
let hevc = args.codec == seiza_parallax::Codec::Hevc;
match args.encoder {
EncoderArg::Png => {
if output.is_file() {
bail!("{} is a file; PNG frames go in a folder", output.display());
}
}
EncoderArg::Ffmpeg => FfmpegSink::check(&args.ffmpeg, output, args.codec)?,
EncoderArg::Openh264 if hevc => {
bail!("the built-in encoder writes H.264 only; use ffmpeg for HEVC")
}
EncoderArg::Openh264 => {}
EncoderArg::Auto if FfmpegSink::available(&args.ffmpeg) => {
FfmpegSink::check(&args.ffmpeg, output, args.codec)?
}
EncoderArg::Auto if hevc => {
bail!("ffmpeg did not run, and HEVC needs it; install it or pass --ffmpeg")
}
EncoderArg::Auto if !cfg!(feature = "openh264") => {
bail!("ffmpeg did not run; install it, pass --ffmpeg, or use --encoder png")
}
EncoderArg::Auto => {}
}
Ok(())
}
fn check_dust_export(args: &ParallaxVideoArgs, path: &Path) -> Result<()> {
let writable = seiza_image_io::Format::from_path(path).is_some_and(|format| format.can_save());
if !writable {
bail!(
"--export-dust-map writes PNG, TIFF, FITS or XISF; name it {}",
seiza_image_io::SAVE_EXTENSIONS
);
}
let canonical = |path: &Path| std::fs::canonicalize(path).ok();
for input in [
&args.image,
&args.starless,
&args.stars,
&args.dust_map,
&args.output,
]
.into_iter()
.flatten()
{
if canonical(input).is_some_and(|input| Some(input) == canonical(path)) {
bail!(
"--export-dust-map {} is also {}; the map would overwrite it",
path.display(),
input.display()
);
}
}
let folder = match path.parent() {
Some(folder) if !folder.as_os_str().is_empty() => folder,
_ => Path::new("."),
};
if !folder.is_dir() {
bail!(
"{} is not a folder to write the dust map in",
folder.display()
);
}
Ok(())
}
fn print_event(event: Event) {
match event {
Event::Note(note) => println!("{note}"),
Event::Warning(warning) => eprintln!("warning: {warning}"),
Event::Frame { done, total } => {
if done % (total / 10).max(1) == 0 || done == total {
println!("rendered {done}/{total} frames");
}
}
}
}
fn auto_tour(args: &ParallaxVideoArgs) -> seiza_parallax::AutoTour {
seiza_parallax::AutoTour {
targets: args.auto_tour.filter(|&targets| targets > 0),
hold: args.tour_hold,
motion: args.tour_motion,
}
}
fn write_plan(args: &ParallaxVideoArgs, path: &Path) -> Result<()> {
let solve_path = args
.image
.clone()
.or_else(|| args.stars.clone())
.expect("checked in run");
let (wcs, dimensions) = solve(args, &solve_path)?;
let plan = seiza_parallax::plan_tour(
&wcs,
dimensions,
args.objects.as_deref(),
args.size,
&auto_tour(args),
)?;
let mut text = format!(
"# A tour of {} planned by seiza parallax-video. Each line is a stop as\n\
# --stop takes it; drop, move or change lines, then pass this file with\n\
# --tour-file. A stop's title shows with --tour-titles. The nebula's\n\
# distance is taken at the focus line.\n\
focus {:.0},{:.0} # {}\n",
solve_path.display(),
plan.focus.0,
plan.focus.1,
plan.focus_name
);
for planned in &plan.stops {
let line = seiza_parallax::format_stop(&planned.stop);
match &planned.name {
Some(name) if planned.stop.title.as_deref() != Some(name) => {
text.push_str(&format!("{line} # {name}\n"))
}
Some(_) => text.push_str(&format!("{line}\n")),
None if planned.stop.focus.is_some() => {
text.push_str(&format!("{line} # pulling back on the way\n"))
}
None => text.push_str(&format!("{line}\n")),
}
}
std::fs::write(path, &text).with_context(|| format!("failed to write {}", path.display()))?;
print!("{text}");
println!("wrote {}", path.display());
Ok(())
}
fn uncommented(line: &str) -> &str {
let mut quoted = false;
let mut escaped = false;
for (at, c) in line.char_indices() {
match c {
'\\' if quoted && !escaped => {
escaped = true;
continue;
}
'"' if !escaped => quoted = !quoted,
'#' if !quoted => return &line[..at],
_ => {}
}
escaped = false;
}
line
}
type TourFile = (Vec<seiza_parallax::TourStop>, Option<(f64, f64)>);
fn read_tour(path: &Path) -> Result<TourFile> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
parse_tour(&text).with_context(|| format!("in {}", path.display()))
}
fn parse_tour(text: &str) -> Result<TourFile> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let (mut stops, mut focus) = (Vec::new(), None);
for (number, line) in text.lines().enumerate() {
let line = uncommented(line).trim();
if line.is_empty() {
continue;
}
let where_ = || format!("line {}", number + 1);
let mut words = line.splitn(2, char::is_whitespace);
if words.next() == Some("focus") {
if focus.is_some() {
bail!("{}: a second focus line; keep one", where_());
}
let place = words.next().unwrap_or_default();
focus = Some(
parse_point(place.trim())
.map_err(anyhow::Error::msg)
.with_context(where_)?,
);
} else {
stops.push(
seiza_parallax::parse_stop(line)
.map_err(anyhow::Error::msg)
.with_context(where_)?,
);
}
}
if stops.len() < 2 {
bail!(
"a tour file needs two stops or more; it has {}",
stops.len()
);
}
Ok((stops, focus))
}
fn options(args: &ParallaxVideoArgs, file: Option<TourFile>) -> ParallaxOptions {
let (file_stops, file_focus) = file.unwrap_or_default();
ParallaxOptions {
scene: SceneOptions {
distance_pc: args.distance,
distance_focus: args.distance_focus.or(file_focus),
unmatched_distance_pc: args.unmatched_distance,
objects: args.objects.clone(),
object_distances: args.distances.clone(),
star_distances: args.star_distances.clone(),
gaia_max_mag: args.gaia_max_mag,
gaia_cache: args.gaia_cache.clone(),
online: true,
max_stars: args.max_stars,
small_stars: match args.small_stars {
SmallStarsArg::Drop => SmallStars::Drop,
SmallStarsArg::Field => SmallStars::Field,
},
keep_galaxies: args.keep_galaxies,
dust: !args.no_dust,
dust_opacity: args.dust_opacity,
dust_map: None,
},
video: VideoOptions {
focus: args.focus,
start: match args.start {
StartArg::Focus => Start::Focus,
StartArg::Whole => Start::Whole,
},
dolly: args.dolly,
truck: args.truck,
truck_angle_deg: args.truck_angle,
pan: args.pan,
zoom: args.zoom,
zoom_end: args.zoom_end,
rotate_deg: (args.rotate, args.rotate_end.unwrap_or(args.rotate)),
easing: match args.easing {
EasingArg::Linear => Easing::Linear,
EasingArg::InOut => Easing::InOut,
},
quality: match args.quality {
QualityArg::Standard => Quality::Standard,
QualityArg::High => Quality::High,
},
growth_limit: args.growth_limit,
fade_from: args.fade_from,
tour: if file_stops.is_empty() {
args.stops.clone()
} else {
file_stops
},
auto_tour: args.auto_tour.map(|_| auto_tour(args)),
tour_glide: args.tour_glide,
tour_titles: args.tour_titles,
tour_loop: args.tour_loop,
size: args.size,
seconds: args.seconds,
fps: args.fps,
overlay: args.overlay,
overlay_density: args.overlay_density,
labels: args.labels.clone(),
label_color: args.label_color.0,
watermark: args.watermark.clone(),
},
}
}
fn split(args: &ParallaxVideoArgs) -> Result<(Rgb32FImage, Rgb32FImage, PathBuf)> {
if let (Some(starless), Some(stars)) = (&args.starless, &args.stars) {
let solve_path = args.image.clone().unwrap_or_else(|| stars.clone());
return Ok((open_display(starless)?, open_display(stars)?, solve_path));
}
let image_path = args.image.as_ref().expect("checked in run");
let image = open_display(image_path)?;
println!("splitting {} with StarXTerminator", image_path.display());
let (starless, stars) = star_x_terminator(&image)?;
if args.keep_split {
let base = args.output.as_deref().unwrap_or(image_path);
let stem = base.file_stem().map_or_else(
|| "parallax".into(),
|stem| stem.to_string_lossy().into_owned(),
);
let directory = base.parent().unwrap_or(Path::new("."));
for (suffix, split) in [("starless", &starless), ("stars", &stars)] {
let path = directory.join(format!("{stem}-{suffix}.png"));
seiza_image_io::save(&path, &seiza_image_io::FloatImage::from_rgb(split))?;
println!("wrote {}", path.display());
}
}
Ok((starless, stars, image_path.clone()))
}
fn star_x_terminator(image: &Rgb32FImage) -> Result<(Rgb32FImage, Rgb32FImage)> {
use seiza_stacking::{LinearImage, RcAstroCli};
let cli = match std::env::var_os("SEIZA_RC_ASTRO") {
Some(path) if !path.is_empty() => RcAstroCli::with_executable(PathBuf::from(path)),
_ => RcAstroCli::locate().context(
"StarXTerminator's rc-astro CLI was not found on PATH (or set SEIZA_RC_ASTRO); \
pass --starless and --stars instead",
)?,
};
let linear = LinearImage::new(
image.width() as usize,
image.height() as usize,
3,
image.as_raw().clone(),
)?;
let mut last = -1;
let cancel = crate::interrupt::cancel_signal();
let (starless, stars) = cli
.split_stars(&linear, Some(&cancel), &mut |fraction| {
let percent = (fraction * 100.0) as i32;
if percent / 10 != last / 10 {
println!("StarXTerminator {percent}%");
last = percent;
}
})
.context(
"StarXTerminator could not split the image; pass --starless and --stars instead",
)?;
let to_display = |image: LinearImage| {
let data = image
.data
.iter()
.map(|value| value.clamp(0.0, 1.0))
.collect();
Rgb32FImage::from_raw(image.width as u32, image.height as u32, data)
.expect("an RGB image of its own size")
};
Ok((to_display(starless), to_display(stars)))
}
fn solve(args: &ParallaxVideoArgs, path: &Path) -> Result<(Wcs, (u32, u32))> {
let data = crate::with_data_flag_hint(seiza::data_paths::star_data(args.data.as_deref()))?;
let index = seiza::data_paths::blind_index_beside(args.index.as_deref(), &data)?;
let options = crate::SolveBlindOptions {
index_path: index.as_deref(),
min_scale: args.min_scale,
max_scale: args.max_scale,
index_mag_limit: 12.7,
max_hypotheses: 400,
max_coarse_hypotheses: 20_000,
sip_order: 2,
sigma: 4.0,
ignore_border: 0,
detection_backend: seiza::DetectBackend::Auto,
detection_fallback: crate::DetectionFallback::F32,
detection_fallback_hypotheses: 64,
annotate: None,
wcs_path: None,
sky_map: None,
};
let image = crate::load_image(path, options.detection_backend)?;
let dims = image.dimensions();
let metadata = if crate::is_astronomy_image_path(path) {
seiza::raster::PhotoMetadata::default()
} else {
seiza::raster::PhotoMetadata::read(path)
};
let search = seiza::raster::ScaleSearch::new(&metadata, dims, args.min_scale, args.max_scale)?;
let config = DetectConfig {
backend: options.detection_backend,
sigma: options.sigma,
max_stars: 600,
..Default::default()
};
let can_retry_f32 = crate::auto_can_retry_f32(path, &image, options.detection_backend);
let mut invocation =
crate::SolveInvocation::new(path, &image, config, options.detection_fallback);
let catalog = seiza::catalog::TileCatalog::open(&data)
.with_context(|| format!("failed to open {}", data.display()))?;
let (solution, elapsed) = crate::blind_solve_invocation(
&mut invocation,
&catalog,
&search,
&options,
can_retry_f32,
dims,
)
.with_context(|| format!("could not plate-solve {}", path.display()))?;
println!(
"solved in {:.1}s: {:.3}\"/px, {} stars matched",
elapsed.as_secs_f64(),
solution.wcs.scale_arcsec_per_px(),
solution.matched_stars
);
Ok((solution.wcs, dims))
}
fn write_layers(directory: &Path, scene: &Scene) -> Result<()> {
std::fs::create_dir_all(directory)
.with_context(|| format!("failed to create {}", directory.display()))?;
let save = |name: &str, image: &LightImage| -> Result<()> {
let path = directory.join(name);
seiza_image_io::save_display(
&path,
&image::DynamicImage::ImageRgb8(image.to_display_rgb8()),
)?;
println!("wrote {}", path.display());
Ok(())
};
save("background.png", scene.background.base())?;
save("leftover.png", scene.leftover.base())?;
let mut sprites = LightImage::new(scene.width(), scene.height());
for sprite in scene.sprites.iter() {
let ratio = sprite.distance_pc / scene.background_distance_pc;
let tint = if ratio < 0.8 {
[1.0, 0.25, 0.25]
} else if ratio <= 1.25 {
[0.25, 1.0, 0.25]
} else {
[0.35, 0.5, 1.0]
};
for y in 0..sprite.image.height {
for x in 0..sprite.image.width {
let light = sprite.image.pixels[y * sprite.image.width + x];
let level = light[0].max(light[1]).max(light[2]);
let pixel = &mut sprites.pixels[(sprite.top + y) * sprites.width + sprite.left + x];
for channel in 0..3 {
pixel[channel] += level * tint[channel];
}
}
}
}
save("sprites.png", &sprites)?;
if let Some(dust) = &scene.dust {
let (cells, columns) = dust.cells();
let path = directory.join("dust.png");
dust.to_map(columns, cells.len() / columns)
.save(&path)
.map_err(anyhow::Error::msg)?;
println!("wrote {}", path.display());
}
Ok(())
}
fn output(args: &ParallaxVideoArgs) -> &Path {
args.output
.as_deref()
.expect("required unless planning a tour or only writing the dust map")
}
fn open_sink(args: &ParallaxVideoArgs, settings: VideoSettings) -> Result<Box<dyn FrameSink>> {
let ffmpeg = || -> Result<Box<dyn FrameSink>> {
Ok(Box::new(FfmpegSink::start(
&args.ffmpeg,
output(args),
settings,
args.codec,
)?))
};
match args.encoder {
EncoderArg::Png => Ok(Box::new(PngSequence::new(output(args), settings)?)),
EncoderArg::Ffmpeg => ffmpeg(),
EncoderArg::Openh264 if args.codec == seiza_parallax::Codec::Hevc => {
bail!("the built-in encoder writes H.264 only; use ffmpeg for HEVC")
}
EncoderArg::Openh264 => openh264(output(args), settings),
EncoderArg::Auto => {
if FfmpegSink::available(&args.ffmpeg) {
ffmpeg()
} else {
openh264(output(args), settings)
.context("ffmpeg did not run; install it, pass --ffmpeg, or use --encoder png")
}
}
}
}
#[cfg(feature = "openh264")]
fn openh264(output: &Path, settings: VideoSettings) -> Result<Box<dyn FrameSink>> {
Ok(Box::new(seiza_parallax::OpenH264Sink::start(
output, settings,
)?))
}
#[cfg(not(feature = "openh264"))]
fn openh264(_output: &Path, _settings: VideoSettings) -> Result<Box<dyn FrameSink>> {
bail!("this build of seiza has no built-in H.264 encoder (the openh264 feature)")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn points_parse() {
assert_eq!(parse_point("10.5, 20"), Ok((10.5, 20.0)));
assert!(parse_point("10").is_err());
}
#[test]
fn tour_files_read_as_editors_write_them() {
let (stops, focus) =
parse_tour("\u{feff}focus\t10,20\r\nwhole hold=1\r\n100,100 dolly=0.5\r\n").unwrap();
assert_eq!((stops.len(), focus), (2, Some((10.0, 20.0))));
assert!(parse_tour("focus 1,2\nfocus 3,4\nwhole\nwhole travel=1\n").is_err());
assert!(parse_tour("focus 1,2\n# only a comment\n").is_err());
}
#[test]
fn a_tour_line_ends_at_a_hash_outside_quotes() {
assert_eq!(uncommented("whole hold=1 # the opening"), "whole hold=1 ");
let quoted = r#"10,20 title="Sh2-170 #2 \"east\"" hold=1 # note"#;
assert_eq!(
uncommented(quoted),
r#"10,20 title="Sh2-170 #2 \"east\"" hold=1 "#
);
let stop = seiza_parallax::parse_stop(uncommented(quoted)).unwrap();
assert_eq!(stop.title.as_deref(), Some(r#"Sh2-170 #2 "east""#));
}
}