fix: clip metadata intervals to playable duration

This commit is contained in:
2026-10-22 13:17:47 +02:00
parent fe34ce88f9
commit 7b53b00b15
4 changed files with 25 additions and 11 deletions
+13 -4
View File
@@ -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)])
+7 -4
View File
@@ -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:
+4 -2
View File
@@ -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")
+1 -1
View File
@@ -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.