From 7b53b00b1507ba02a1e4c2f94134240bcfac58f0 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 13:17:47 +0200 Subject: [PATCH] fix: clip metadata intervals to playable duration --- Checks/miruro-metadata.swift | 17 +++++++++++++---- Sources/App/MiruroMetadata.swift | 11 +++++++---- Sources/App/TerebiApp.swift | 6 ++++-- docs/miruro-playback-handoff.md | 2 +- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/Checks/miruro-metadata.swift b/Checks/miruro-metadata.swift index 018ae87..9118908 100644 --- a/Checks/miruro-metadata.swift +++ b/Checks/miruro-metadata.swift @@ -167,15 +167,24 @@ struct MiruroMetadataChecks { for duration in [0, -1, Double.nan, .infinity] { try require(MiruroMetadata(title: nil, intervals: [valid]).visibleIntervals(episode: 1, duration: duration).isEmpty, "wait for finite positive actual duration") } - let invalid = [interval(.intro, -1, 10), interval(.intro, 10, 10), interval(.intro, 20, 10), - interval(.intro, .nan, 10), interval(.intro, 0, .infinity), interval(.intro, 0, 101), + let invalid = [interval(.intro, -10, 0), interval(.intro, 100, 110), + interval(.intro, 10, 10), interval(.intro, 20, 10), + interval(.intro, .nan, 10), interval(.intro, 0, .infinity), interval(episode: 2), interval(votes: -1), interval(votes: .nan), interval(provider: "SomeCrunchyrollProvider"), interval(length: 106)] try require(MiruroMetadata(title: nil, intervals: invalid).visibleIntervals(episode: 1, duration: 100).isEmpty, "bounds, episode, votes, provenance and reference-length rejection") let metadata = MiruroMetadata(title: nil, intervals: [valid]) try require(metadata.visibleIntervals(episode: 1, duration: 100) == [valid], "end equal to duration allowed") - try require(metadata.visibleIntervals(episode: 1, duration: 99).isEmpty, "duration shrink revalidates without trimming") - try require(metadata.visibleIntervals(episode: 1, duration: 120) == [valid], "duration growth revalidates without scaling") + try require(metadata.visibleIntervals(episode: 1, duration: 99) == [interval(.intro, 10, 99)], "duration shrink clips the visible interval") + try require(metadata.visibleIntervals(episode: 1, duration: 120) == [valid] && metadata.intervals == [valid], "duration growth restores unchanged raw timestamps") + let overlapping = MiruroMetadata(title: nil, intervals: [ + interval(.intro, -5.5, 10.25), interval(.outro, 90.75, 110.5)]) + try require(overlapping.visibleIntervals(episode: 1, duration: 100) == [ + interval(.intro, 0, 10.25), interval(.outro, 90.75, 100)], "partial overlaps clip at both playback boundaries") + try require(overlapping.visibleIntervals(episode: 1, duration: 120) == [ + interval(.intro, 0, 10.25), interval(.outro, 90.75, 110.5)], "growth recomputes clipping from raw timestamps") + try require(MiruroMetadata(title: nil, intervals: [interval(.intro, -10, 110)]) + .visibleIntervals(episode: 1, duration: 100) == [interval(.intro, 0, 100)], "interval spanning the video clips to the playable range") let first = interval(.intro, 5, 15, votes: 4), tied = interval(.intro, 20, 30, votes: 4) let ending = interval(.outro, 12, 40, votes: 2) let ranked = MiruroMetadata(title: nil, intervals: [interval(), first, tied, ending, interval(.outro, 50, 60, votes: 1)]) diff --git a/Sources/App/MiruroMetadata.swift b/Sources/App/MiruroMetadata.swift index f6b82f4..5f7d9a8 100644 --- a/Sources/App/MiruroMetadata.swift +++ b/Sources/App/MiruroMetadata.swift @@ -4,8 +4,8 @@ import CoreFoundation struct MiruroInterval: Sendable, Equatable { enum Kind: Sendable { case intro, outro } let kind: Kind - let start: Double - let end: Double + var start: Double + var end: Double let episode: Int var rawKind: String = "" var provider: String? = nil @@ -20,13 +20,16 @@ struct MiruroMetadata: Sendable { func visibleIntervals(episode: Int, duration: Double) -> [MiruroInterval] { guard duration.isFinite, duration > 0 else { return [] } var intro: MiruroInterval?, outro: MiruroInterval? - for row in intervals { + for var row in intervals { guard row.episode == episode, row.start.isFinite, row.end.isFinite, - row.start >= 0, row.end > row.start, row.end <= duration, + row.end > row.start, !(row.provider?.range(of: "crunchyroll", options: .caseInsensitive) != nil), (row.votes ?? 0).isFinite, (row.votes ?? 0) >= 0 else { continue } if let length = row.referenceLength, length.isFinite, length > 0, abs(length - duration) / duration > 0.05 { continue } + row.start = max(0, row.start) + row.end = min(duration, row.end) + guard row.end > row.start else { continue } // Strictly greater preserves input order on ties, equivalent to stable descending votes. switch row.kind { case .intro: diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 38beeca..e0b6ec4 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -1614,8 +1614,10 @@ final class PlayerModel: ObservableObject { precondition(lifecycle.accept(metadata, identity: a, generation: first), "Current metadata is accepted") precondition(lifecycle.visibleIntervals(duration: 0).isEmpty, "Unknown duration hides bands") precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Actual duration permits exact-end bands") - precondition(lifecycle.visibleIntervals(duration: 95).count == 1, "Duration shrink revalidates raw bands") - precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Duration growth restores raw bands") + precondition(lifecycle.visibleIntervals(duration: 95) == [ + metadata.intervals[0], MiruroInterval(kind: .outro, start: 90, end: 95, episode: 1) + ], "Duration shrink clips raw bands") + precondition(lifecycle.visibleIntervals(duration: 100) == metadata.intervals, "Duration growth restores raw bands") _ = lifecycle.replace(identity: b, episode: 2) precondition(lifecycle.metadata == nil, "Replacement clears title and intervals") precondition(!lifecycle.accept(metadata, identity: a, generation: first), "Different-identity result is stale") diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index b6473ea..ef0f551 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -29,7 +29,7 @@ Create a private reporting-session directory (`0700`) and supply a **nonexistent The native resolver requests watch-route data and safely follows bounded same-origin, same-ID, same-episode canonicalization envelopes, including HTTP-200 redirects. It accepts JSON and newline-delimited `text/sveltekit-data`, decoding only the needed response-local tables and episode promise. Bodies and traversal are bounded; unrelated source promises are not used for playback. -Titles come from returned series/selected-episode metadata. Only `op`/`mixed-op` and `ed`/`mixed-ed` become opening/ending bands. Bands retain fractional seconds, require positive player-reported duration, and reject nonfinite, negative, reversed, wrong-episode or out-of-bounds timings; an end equal to duration is valid. Duration changes revalidate raw intervals without trimming, shifting or scaling. Optional provenance/votes/reference-length checks follow the documented frontend evidence; missing fields are not invented. Highest-vote eligible opening and ending win, with response order breaking ties. Overlaps are not merged. +Titles come from returned series/selected-episode metadata. Only `op`/`mixed-op` and `ed`/`mixed-ed` become opening/ending bands. Bands retain fractional seconds, require finite positive player-reported duration, and reject nonfinite timestamps, originally reversed/zero-length intervals and wrong-episode timings. Display intervals are clipped to `[0, duration]`; intervals with no positive-length overlap are hidden. Raw timestamps remain unchanged, and duration changes recompute clipping without shifting or scaling. Optional provenance/votes/reference-length checks remain in force; a finite positive reference length differing from actual duration by more than 5% rejects the interval, and missing fields are not invented. Highest-vote eligible opening and ending win, with response order breaking ties. Overlaps are not merged. The controls mark interval boundaries with small rounded gaps in the slider track, without visible labels or boxes. Buffer rendering shares the segmented track; accessibility descriptions retain opening/ending names and times. Neither automatic seeking nor skip buttons are added. Bounds do not prove synchronization across cuts. Metadata tasks are bound to identity and lifecycle generation; replacement clears intervals and rejects stale results, including A → B → A.