Use chrono instead of hand-rolled UTC conversion (#2662)

* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies (app log
timestamp, Media Browser date column, auto-save file-name stamp, MXF header
timestamp, golden-image attribution date) with chrono as a workspace
dependency. The existing chrono entry on apps/filmcraft (clock feature) was
lifted to the workspace. Output formats are unchanged and pinned by tests.
AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked. logging::timestamp and golden::today now read the duration since the
epoch and go through the fallible DateTime::from_timestamp, reading an
out-of-range or pre-epoch clock as the epoch as the old saturating code did.
timestamp, today and media_browser::format_date write the year with {:04}
rather than %Y, which prefixes a '+' past year 9999.

Tests cover the far-future and year-10000 cases for timestamp and format_date.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve out-of-range years instead of the epoch (FredApps review)

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output:
inputs the old hand-rolled civil-from-days converted (e.g. `10_000_000_000_000`
s → 318857-05-20 17:46:40) collapsed to `1970-01-01 00:00:00`. Restore the old
conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian
cycles (146_097 days), convert the in-range remainder with chrono, and carry the
year back as i128 (saturating to the output type). Only the year leaves chrono's
range; month, day and time of day are always exact.

Sites (all UTC):
- apps/filmcraft logging.rs `timestamp` (SystemTime, u64 secs): pre-1970 still
  clamps to the epoch; a far-future clock keeps its year. u64::MAX/2 s →
  "292277026596-12-04T15:30:07.000Z", 10e12 s → "318857-05-20T17:46:40.000Z"
  (was "1970-01-01..."). SystemTime cases built with a checked add (Windows-safe).
- crates/engine media_browser.rs `format_date` (u64): 10e12 s → "318857-05-20
  17:46", u64::MAX → "584554051223-11-09 07:00" (was "1970-01-01 00:00").
- crates/format autosave.rs `civil` (i64 + i32 offset): keeps years, e.g.
  i64::MAX → 292277026596-12-04 15:30:07, i64::MIN → -292277022657-01-27. The
  separate `unix + offset_secs` overflow guard (checked_add) is kept: only an
  overflowing shift (old code wrapped it to garbage) reads as the epoch.
- crates/mxf from_unix (i64): keeps month/day/time, clamps only `year` to the
  u16 field as before. 10e12 s → year 65535, May 20 17:46:40; -10e12 s → year 0,
  Aug 13 06:13:20.
- crates/testkit golden.rs `today` (u64): same helper; pinned via `utc_parts`.

chrono stays the only calendar logic (NaiveDate::from_num_days_from_ce_opt on the
in-cycle remainder). No new chrono features; still default-features = false, std.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
This commit is contained in:
Dylan M. Taylor
2026-10-23 11:18:37 -04:00
committed by GitHub
parent 6f87c0e381
commit c5c1693989
15 changed files with 231 additions and 80 deletions
+2
View File
@@ -127,6 +127,8 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
+1
View File
@@ -16,6 +16,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
- **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id.
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde so the control channel can read/set it.
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm` (checks L0–L4 crates). `cargo xtask ci` runs them all.
Generated
+4
View File
@@ -1909,6 +1909,7 @@ dependencies = [
name = "filmcraft-engine"
version = "0.12.0"
dependencies = [
"chrono",
"craft-docpath",
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -1984,6 +1985,7 @@ dependencies = [
name = "filmcraft-format"
version = "0.12.0"
dependencies = [
"chrono",
"filmcraft-media",
"filmcraft-project",
"filmcraft-time",
@@ -2160,6 +2162,7 @@ dependencies = [
name = "filmcraft-mxf"
version = "0.12.0"
dependencies = [
"chrono",
"filmcraft-testkit",
]
@@ -2278,6 +2281,7 @@ dependencies = [
name = "filmcraft-testkit"
version = "0.12.0"
dependencies = [
"chrono",
"png",
]
+2
View File
@@ -67,6 +67,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
roxmltree = "0.21"
+1 -1
View File
@@ -36,7 +36,7 @@ rfd = { workspace = true }
cpal = "0.16"
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
chrono = { workspace = true, features = ["clock"] }
sys-locale = "0.3"
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
open = "5"
+36 -19
View File
@@ -97,29 +97,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -390,6 +396,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch (reviewer FredApps). Windows' SystemTime can't hold such an
// instant, so it is built with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+1
View File
@@ -42,6 +42,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
craft-docpath = { workspace = true }
web-time = "1"
+46 -15
View File
@@ -636,22 +636,33 @@ fn format_size(b: u64) -> String {
}
}
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds.
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
/// month, day and time of day come from chrono and only the year is carried, which keeps every
/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days`
/// (≤ `u64::MAX / 86_400`) always fits an `i64`.
fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds. Years far outside chrono's own range are preserved
/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
pub fn format_date(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
// civil-from-days (Howard Hinnant's algorithm)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem / 60) % 60)
let (y, t) = utc_parts(secs);
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%d %H:%M"))
}
fn probe_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -773,3 +784,23 @@ pub fn std_home_dir() -> Option<String> {
let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_date_is_utc_minutes() {
// Pinned against the previous hand-rolled civil-from-days output.
assert_eq!(format_date(0), "1970-01-01 00:00");
assert_eq!(format_date(951_782_400), "2000-02-29 00:00");
assert_eq!(format_date(1_709_164_800), "2024-02-29 00:00");
assert_eq!(format_date(1_790_775_907), "2026-09-30 13:45");
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(format_date(253_402_300_800), "10000-01-01 00:00");
// Values far beyond chrono's own range are preserved (the old hand-rolled civil-from-days
// kept them); they must not collapse to the epoch (reviewer FredApps).
assert_eq!(format_date(10_000_000_000_000), "318857-05-20 17:46");
assert_eq!(format_date(u64::MAX), "584554051223-11-09 07:00");
}
}
+1
View File
@@ -16,6 +16,7 @@ log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3.27"
+47 -15
View File
@@ -25,23 +25,37 @@ pub fn file_stem(name: &str) -> String {
if s.is_empty() { "Untitled".into() } else { s }
}
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
/// month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact instead of collapsing out-of-range years to the epoch.
fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// Civil date-time (proleptic Gregorian) of a unix timestamp shifted by `offset_secs`.
/// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept them);
/// only an overflowing `unix + offset_secs` shift (which the old code wrapped to garbage) reads as
/// the epoch.
pub fn civil(unix: i64, offset_secs: i32) -> (i64, u32, u32, u32, u32, u32) {
let t = unix + offset_secs as i64;
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
// Howard Hinnant's days_from_civil inverse.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d, (secs / 3600) as u32, (secs % 3600 / 60) as u32, (secs % 60) as u32)
use chrono::{Datelike, Timelike};
let Some(s) = unix.checked_add(offset_secs as i64) else {
return (1970, 1, 1, 0, 0, 0);
};
let (y, t) = utc_parts(s);
(y, t.month(), t.day(), t.hour(), t.minute(), t.second())
}
/// `YYYY-MM-DD_HH-MM-SS` (the auto-save file-name stamp).
@@ -137,6 +151,24 @@ mod tests {
assert_eq!(timestamp(-1, 0), "1969-12-31_23-59-59");
}
#[test]
fn out_of_range_years_are_preserved_not_collapsed_to_the_epoch() {
// The old hand-rolled conversion kept years far outside chrono's own range; the refactor
// must too (reviewer FredApps). `10_000_000_000_000` s → 318857, not 1970.
assert_eq!(timestamp(10_000_000_000_000, 0), "318857-05-20_17-46-40");
assert_eq!(display_time(10_000_000_000_000, 0), "318857-05-20 17:46:40");
// chrono's own upper edge (year 10000) and a negative value far before it.
assert_eq!(timestamp(253_402_300_800, 0), "10000-01-01_00-00-00");
assert_eq!(display_time(-10_000_000_000_000, 0), "-314918-08-13 06:13:20");
// The input extremes the old code accepted (offset 0, so no shift overflow).
assert_eq!(display_time(i64::MAX, 0), "292277026596-12-04 15:30:07");
assert_eq!(display_time(i64::MIN, 0), "-292277022657-01-27 08:29:52");
// Only an overflowing `unix + offset_secs` shift (old code wrapped it to garbage) reads as
// the epoch; the separate overflow guard is kept.
assert_eq!(civil(i64::MAX, 3600), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(i64::MIN, -3600), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn names_and_stems() {
assert_eq!(auto_save_name("My Film", 0, 0), "My Film-1970-01-01_00-00-00-0.fcproj");
+1
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
chrono = { workspace = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+30 -18
View File
@@ -243,6 +243,26 @@ impl StartTimecode {
}
}
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
/// month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact instead of collapsing out-of-range years to the epoch.
fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// An MXF timestamp (UTC).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Timestamp {
@@ -256,27 +276,19 @@ pub struct Timestamp {
}
impl Timestamp {
/// From seconds since the Unix epoch (proleptic Gregorian, UTC).
/// From seconds since the Unix epoch (proleptic Gregorian, UTC). Years far outside chrono's own
/// range are preserved (the old hand-rolled conversion kept them) with month, day and time
/// intact; only the `year` is clamped to the MXF `u16` field, exactly as the old code did.
pub fn from_unix(secs: i64) -> Timestamp {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
// civil-from-days (H. Hinnant)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
use chrono::{Datelike, Timelike};
let (y, t) = utc_parts(secs);
Timestamp {
year: y.clamp(0, 65535) as u16,
month: m as u8,
day: d as u8,
hour: (rem / 3600) as u8,
minute: (rem / 60 % 60) as u8,
second: (rem % 60) as u8,
month: t.month() as u8,
day: t.day() as u8,
hour: t.hour() as u8,
minute: t.minute() as u8,
second: t.second() as u8,
millisecond: 0,
}
}
+16
View File
@@ -267,6 +267,22 @@ fn timestamps() {
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20));
let t = Timestamp::from_unix(951_782_400); // 2000-02-29
assert_eq!((t.year, t.month, t.day), (2000, 2, 29));
// Years far outside chrono's own range are preserved with month/day/time intact and only the
// year clamped to the MXF `u16`, exactly as the old hand-rolled conversion did (not collapsed
// to the epoch — reviewer FredApps). `10_000_000_000_000` s → 318857-05-20 17:46:40.
let t = Timestamp::from_unix(10_000_000_000_000);
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 5, 20, 17, 46, 40));
// Year 10000 (just past chrono's range) still fits the u16 field.
let t = Timestamp::from_unix(253_402_300_800);
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (10000, 1, 1, 0, 0, 0));
// A negative year clamps to 0 but keeps its month/day/time.
let t = Timestamp::from_unix(-10_000_000_000_000);
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 8, 13, 6, 13, 20));
// The `i64` extremes the old code accepted.
let t = Timestamp::from_unix(i64::MAX);
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 12, 4, 15, 30, 7));
let t = Timestamp::from_unix(i64::MIN);
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 1, 27, 8, 29, 52));
}
#[test]
+1
View File
@@ -14,3 +14,4 @@ workspace = true
# use it without creating dependency cycles.
[dependencies]
png = "0.18"
chrono = { workspace = true }
+42 -12
View File
@@ -195,18 +195,31 @@ pub fn diff_image(a: &Rgba8, b: &Rgba8) -> Rgba8 {
/// Today's date (UTC) as YYYY-MM-DD, for attribution sidecars.
fn today() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
// civil-from-days (Howard Hinnant)
let z = (secs / 86_400) as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + (m <= 2) as i64;
format!("{y:04}-{m:02}-{d:02}")
let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let (y, t) = utc_parts(since.as_secs());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%d"))
}
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
/// month and day come from chrono and only the year is carried, which keeps every input exact
/// instead of collapsing out-of-range years to the epoch. `secs` is `u64` (the clock is clamped at
/// the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// The attribution sidecar text for a generated golden image.
@@ -283,6 +296,23 @@ pub fn assert_golden(golden: &Path, actual: &Rgba8, tol: Tolerance, title: &str,
mod tests {
use super::*;
fn ymd(secs: u64) -> String {
let (y, t) = utc_parts(secs);
format!("{y:04}-{}", t.format("%m-%d"))
}
#[test]
fn utc_parts_preserves_out_of_range_years() {
// `today()` uses `utc_parts`. Years far outside chrono's own range are preserved (the old
// hand-rolled civil-from-days kept them) rather than collapsing to the epoch.
assert_eq!(ymd(0), "1970-01-01");
assert_eq!(ymd(1_790_775_907), "2026-09-30");
assert_eq!(ymd(951_782_400), "2000-02-29"); // 2000 is a leap year
assert_eq!(ymd(253_402_300_800), "10000-01-01"); // just past chrono's range
assert_eq!(ymd(10_000_000_000_000), "318857-05-20");
assert_eq!(ymd(u64::MAX), "584554051223-11-09");
}
fn ramp(w: u32, h: u32, alpha: u8) -> Rgba8 {
let px = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 77, alpha]).collect();
Rgba8::new(w, h, px)