reactor-effect-client 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +7 -2
- package/README.md +103 -141
- package/dist/Coordinator.d.ts +382 -0
- package/dist/Coordinator.d.ts.map +1 -0
- package/dist/Coordinator.js +732 -0
- package/dist/Coordinator.js.map +1 -0
- package/dist/H3.d.ts +159 -0
- package/dist/H3.d.ts.map +1 -0
- package/dist/H3.js +21 -0
- package/dist/H3.js.map +1 -0
- package/dist/H3Source.d.ts +93 -0
- package/dist/H3Source.d.ts.map +1 -0
- package/dist/H3Source.js +401 -0
- package/dist/H3Source.js.map +1 -0
- package/dist/LocalSource.d.ts +68 -0
- package/dist/LocalSource.d.ts.map +1 -0
- package/dist/LocalSource.js +354 -0
- package/dist/LocalSource.js.map +1 -0
- package/dist/Media.d.ts +106 -0
- package/dist/Media.d.ts.map +1 -0
- package/dist/Media.js +16 -0
- package/dist/Media.js.map +1 -0
- package/dist/Peer.d.ts +134 -0
- package/dist/Peer.d.ts.map +1 -0
- package/dist/Peer.js +57 -0
- package/dist/Peer.js.map +1 -0
- package/dist/Playout.d.ts +724 -0
- package/dist/Playout.d.ts.map +1 -0
- package/dist/Playout.js +28 -0
- package/dist/Playout.js.map +1 -0
- package/dist/Reactor.d.ts +135 -0
- package/dist/Reactor.d.ts.map +1 -0
- package/dist/Reactor.js +158 -0
- package/dist/Reactor.js.map +1 -0
- package/dist/ReactorError.d.ts +331 -0
- package/dist/ReactorError.d.ts.map +1 -0
- package/dist/ReactorError.js +316 -0
- package/dist/ReactorError.js.map +1 -0
- package/dist/ReactorTest.d.ts +383 -0
- package/dist/ReactorTest.d.ts.map +1 -0
- package/dist/ReactorTest.js +317 -0
- package/dist/ReactorTest.js.map +1 -0
- package/dist/Session.d.ts +314 -0
- package/dist/Session.d.ts.map +1 -0
- package/dist/Session.js +51 -0
- package/dist/Session.js.map +1 -0
- package/dist/index.d.ts +12 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -11
- package/dist/index.js.map +1 -1
- package/dist/internal/correlator.d.ts +46 -0
- package/dist/internal/correlator.d.ts.map +1 -0
- package/dist/internal/correlator.js +150 -0
- package/dist/internal/correlator.js.map +1 -0
- package/dist/internal/deadline.d.ts +24 -0
- package/dist/internal/deadline.d.ts.map +1 -0
- package/dist/internal/deadline.js +44 -0
- package/dist/internal/deadline.js.map +1 -0
- package/dist/{h3/_internal/contracts.d.ts → internal/h3/commands.d.ts} +39 -33
- package/dist/internal/h3/commands.d.ts.map +1 -0
- package/dist/internal/h3/commands.js +128 -0
- package/dist/internal/h3/commands.js.map +1 -0
- package/dist/{h3 → internal/h3}/messages.d.ts +16 -11
- package/dist/internal/h3/messages.d.ts.map +1 -0
- package/dist/{h3 → internal/h3}/messages.js +19 -31
- package/dist/internal/h3/messages.js.map +1 -0
- package/dist/internal/h3/operations.d.ts +145 -0
- package/dist/internal/h3/operations.d.ts.map +1 -0
- package/dist/internal/h3/operations.js +284 -0
- package/dist/internal/h3/operations.js.map +1 -0
- package/dist/{h3 → internal/h3}/profile.d.ts +63 -65
- package/dist/internal/h3/profile.d.ts.map +1 -0
- package/dist/{h3 → internal/h3}/profile.js +39 -39
- package/dist/internal/h3/profile.js.map +1 -0
- package/dist/internal/h3/provider.d.ts +14 -0
- package/dist/internal/h3/provider.d.ts.map +1 -0
- package/dist/internal/h3/provider.js +672 -0
- package/dist/internal/h3/provider.js.map +1 -0
- package/dist/internal/h3/references.d.ts +68 -0
- package/dist/internal/h3/references.d.ts.map +1 -0
- package/dist/internal/h3/references.js +339 -0
- package/dist/internal/h3/references.js.map +1 -0
- package/dist/internal/h3/request.d.ts +56 -0
- package/dist/internal/h3/request.d.ts.map +1 -0
- package/dist/internal/h3/request.js +70 -0
- package/dist/internal/h3/request.js.map +1 -0
- package/dist/internal/h3/state.d.ts +120 -0
- package/dist/internal/h3/state.d.ts.map +1 -0
- package/dist/internal/h3/state.js +270 -0
- package/dist/internal/h3/state.js.map +1 -0
- package/dist/{Submission.d.ts → internal/h3/submission.d.ts} +1 -3
- package/dist/internal/h3/submission.d.ts.map +1 -0
- package/dist/{Submission.js → internal/h3/submission.js} +1 -9
- package/dist/internal/h3/submission.js.map +1 -0
- package/dist/internal/hub.d.ts +32 -0
- package/dist/internal/hub.d.ts.map +1 -0
- package/dist/internal/hub.js +92 -0
- package/dist/internal/hub.js.map +1 -0
- package/dist/internal/playout/errors.d.ts +51 -0
- package/dist/internal/playout/errors.d.ts.map +1 -0
- package/dist/internal/playout/errors.js +40 -0
- package/dist/internal/playout/errors.js.map +1 -0
- package/dist/internal/playout/policy.d.ts +559 -0
- package/dist/internal/playout/policy.d.ts.map +1 -0
- package/dist/internal/playout/policy.js +2338 -0
- package/dist/internal/playout/policy.js.map +1 -0
- package/dist/internal/playout/runtime.d.ts +28 -0
- package/dist/internal/playout/runtime.d.ts.map +1 -0
- package/dist/internal/playout/runtime.js +766 -0
- package/dist/internal/playout/runtime.js.map +1 -0
- package/dist/internal/playout/tag.d.ts +14 -0
- package/dist/internal/playout/tag.d.ts.map +1 -0
- package/dist/internal/playout/tag.js +43 -0
- package/dist/internal/playout/tag.js.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/common_pb.d.ts +55 -0
- package/dist/internal/proto/reactor_wire/v1/common_pb.d.ts.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/common_pb.js +42 -0
- package/dist/internal/proto/reactor_wire/v1/common_pb.js.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/control_pb.d.ts +160 -0
- package/dist/internal/proto/reactor_wire/v1/control_pb.d.ts.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/control_pb.js +21 -0
- package/dist/internal/proto/reactor_wire/v1/control_pb.js.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/data_pb.d.ts +89 -0
- package/dist/internal/proto/reactor_wire/v1/data_pb.d.ts.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/data_pb.js +20 -0
- package/dist/internal/proto/reactor_wire/v1/data_pb.js.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/model_pb.d.ts +85 -0
- package/dist/internal/proto/reactor_wire/v1/model_pb.d.ts.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/model_pb.js +24 -0
- package/dist/internal/proto/reactor_wire/v1/model_pb.js.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/platform_pb.d.ts +190 -0
- package/dist/internal/proto/reactor_wire/v1/platform_pb.d.ts.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/platform_pb.js +54 -0
- package/dist/internal/proto/reactor_wire/v1/platform_pb.js.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/track_pb.d.ts +88 -0
- package/dist/internal/proto/reactor_wire/v1/track_pb.d.ts.map +1 -0
- package/dist/internal/proto/reactor_wire/v1/track_pb.js +33 -0
- package/dist/internal/proto/reactor_wire/v1/track_pb.js.map +1 -0
- package/dist/internal/queue.d.ts +4 -0
- package/dist/internal/queue.d.ts.map +1 -0
- package/dist/{_internal → internal}/queue.js +0 -9
- package/dist/internal/queue.js.map +1 -0
- package/dist/internal/reactorTest/coordinator.d.ts +5 -0
- package/dist/internal/reactorTest/coordinator.d.ts.map +1 -0
- package/dist/internal/reactorTest/coordinator.js +178 -0
- package/dist/internal/reactorTest/coordinator.js.map +1 -0
- package/dist/internal/reactorTest/faults.d.ts +172 -0
- package/dist/internal/reactorTest/faults.d.ts.map +1 -0
- package/dist/internal/reactorTest/faults.js +23 -0
- package/dist/internal/reactorTest/faults.js.map +1 -0
- package/dist/internal/reactorTest/h3.d.ts +431 -0
- package/dist/internal/reactorTest/h3.d.ts.map +1 -0
- package/dist/internal/reactorTest/h3.js +625 -0
- package/dist/internal/reactorTest/h3.js.map +1 -0
- package/dist/internal/reactorTest/media.d.ts +22 -0
- package/dist/internal/reactorTest/media.d.ts.map +1 -0
- package/dist/internal/reactorTest/media.js +34 -0
- package/dist/internal/reactorTest/media.js.map +1 -0
- package/dist/internal/reactorTest/peer.d.ts +318 -0
- package/dist/internal/reactorTest/peer.d.ts.map +1 -0
- package/dist/internal/reactorTest/peer.js +206 -0
- package/dist/internal/reactorTest/peer.js.map +1 -0
- package/dist/internal/reactorTest/playout.d.ts +34 -0
- package/dist/internal/reactorTest/playout.d.ts.map +1 -0
- package/dist/internal/reactorTest/playout.js +340 -0
- package/dist/internal/reactorTest/playout.js.map +1 -0
- package/dist/internal/reactorTest/references.d.ts +25 -0
- package/dist/internal/reactorTest/references.d.ts.map +1 -0
- package/dist/{testing/Png.js → internal/reactorTest/references.js} +50 -12
- package/dist/internal/reactorTest/references.js.map +1 -0
- package/dist/internal/reactorTest/sessions.d.ts +364 -0
- package/dist/internal/reactorTest/sessions.d.ts.map +1 -0
- package/dist/internal/reactorTest/sessions.js +592 -0
- package/dist/internal/reactorTest/sessions.js.map +1 -0
- package/dist/internal/reactorTest/timing.d.ts +13 -0
- package/dist/internal/reactorTest/timing.d.ts.map +1 -0
- package/dist/internal/reactorTest/timing.js +20 -0
- package/dist/internal/reactorTest/timing.js.map +1 -0
- package/dist/internal/recording.d.ts +52 -0
- package/dist/internal/recording.d.ts.map +1 -0
- package/dist/internal/recording.js +141 -0
- package/dist/internal/recording.js.map +1 -0
- package/dist/internal/session/connect.d.ts +34 -0
- package/dist/internal/session/connect.d.ts.map +1 -0
- package/dist/internal/session/connect.js +502 -0
- package/dist/internal/session/connect.js.map +1 -0
- package/dist/internal/session/generation.d.ts +35 -0
- package/dist/internal/session/generation.d.ts.map +1 -0
- package/dist/internal/session/generation.js +185 -0
- package/dist/internal/session/generation.js.map +1 -0
- package/dist/internal/session/ice.d.ts +23 -0
- package/dist/internal/session/ice.d.ts.map +1 -0
- package/dist/internal/session/ice.js +57 -0
- package/dist/internal/session/ice.js.map +1 -0
- package/dist/internal/session/inbound.d.ts +50 -0
- package/dist/internal/session/inbound.d.ts.map +1 -0
- package/dist/internal/session/inbound.js +202 -0
- package/dist/internal/session/inbound.js.map +1 -0
- package/dist/internal/session/model.d.ts +163 -0
- package/dist/internal/session/model.d.ts.map +1 -0
- package/dist/internal/session/model.js +78 -0
- package/dist/internal/session/model.js.map +1 -0
- package/dist/internal/session/requests.d.ts +71 -0
- package/dist/internal/session/requests.d.ts.map +1 -0
- package/dist/internal/session/requests.js +240 -0
- package/dist/internal/session/requests.js.map +1 -0
- package/dist/internal/session/tracks.d.ts +21 -0
- package/dist/internal/session/tracks.d.ts.map +1 -0
- package/dist/internal/session/tracks.js +166 -0
- package/dist/internal/session/tracks.js.map +1 -0
- package/dist/internal/session/upload.d.ts +21 -0
- package/dist/internal/session/upload.d.ts.map +1 -0
- package/dist/internal/session/upload.js +68 -0
- package/dist/internal/session/upload.js.map +1 -0
- package/dist/internal/session.d.ts +66 -0
- package/dist/internal/session.d.ts.map +1 -0
- package/dist/internal/session.js +280 -0
- package/dist/internal/session.js.map +1 -0
- package/dist/{stats.d.ts → internal/stats.d.ts} +30 -16
- package/dist/internal/stats.d.ts.map +1 -0
- package/dist/internal/stats.js +201 -0
- package/dist/internal/stats.js.map +1 -0
- package/dist/internal/token.d.ts +22 -0
- package/dist/internal/token.d.ts.map +1 -0
- package/dist/internal/token.js +65 -0
- package/dist/internal/token.js.map +1 -0
- package/dist/internal/wire.d.ts +37 -0
- package/dist/internal/wire.d.ts.map +1 -0
- package/dist/internal/wire.js +49 -0
- package/dist/internal/wire.js.map +1 -0
- package/notices/bufbuild-protobuf-LICENSE +201 -0
- package/package.json +17 -38
- package/dist/FetchHttp.d.ts +0 -9
- package/dist/FetchHttp.d.ts.map +0 -1
- package/dist/FetchHttp.js +0 -63
- package/dist/FetchHttp.js.map +0 -1
- package/dist/PeerFactory.d.ts +0 -26
- package/dist/PeerFactory.d.ts.map +0 -1
- package/dist/PeerFactory.js +0 -4
- package/dist/PeerFactory.js.map +0 -1
- package/dist/PeerTypes.d.ts +0 -64
- package/dist/PeerTypes.d.ts.map +0 -1
- package/dist/PeerTypes.js +0 -2
- package/dist/PeerTypes.js.map +0 -1
- package/dist/Sequence.d.ts +0 -85
- package/dist/Sequence.d.ts.map +0 -1
- package/dist/Sequence.js +0 -210
- package/dist/Sequence.js.map +0 -1
- package/dist/SessionTypes.d.ts +0 -312
- package/dist/SessionTypes.d.ts.map +0 -1
- package/dist/SessionTypes.js +0 -19
- package/dist/SessionTypes.js.map +0 -1
- package/dist/Submission.d.ts.map +0 -1
- package/dist/Submission.js.map +0 -1
- package/dist/_internal/queue.d.ts +0 -6
- package/dist/_internal/queue.d.ts.map +0 -1
- package/dist/_internal/queue.js.map +0 -1
- package/dist/bytes.d.ts +0 -6
- package/dist/bytes.d.ts.map +0 -1
- package/dist/bytes.js +0 -32
- package/dist/bytes.js.map +0 -1
- package/dist/contract.d.ts +0 -149
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/coordinator/_internal/client.d.ts +0 -215
- package/dist/coordinator/_internal/client.d.ts.map +0 -1
- package/dist/coordinator/_internal/client.js +0 -563
- package/dist/coordinator/_internal/client.js.map +0 -1
- package/dist/coordinator/_internal/recording.d.ts +0 -31
- package/dist/coordinator/_internal/recording.d.ts.map +0 -1
- package/dist/coordinator/_internal/recording.js +0 -123
- package/dist/coordinator/_internal/recording.js.map +0 -1
- package/dist/coordinator/_internal/response.d.ts +0 -15
- package/dist/coordinator/_internal/response.d.ts.map +0 -1
- package/dist/coordinator/_internal/response.js +0 -55
- package/dist/coordinator/_internal/response.js.map +0 -1
- package/dist/coordinator/_internal/schemas.d.ts +0 -73
- package/dist/coordinator/_internal/schemas.d.ts.map +0 -1
- package/dist/coordinator/_internal/schemas.js +0 -200
- package/dist/coordinator/_internal/schemas.js.map +0 -1
- package/dist/coordinator/index.d.ts +0 -40
- package/dist/coordinator/index.d.ts.map +0 -1
- package/dist/coordinator/index.js +0 -21
- package/dist/coordinator/index.js.map +0 -1
- package/dist/correlation.d.ts +0 -47
- package/dist/correlation.d.ts.map +0 -1
- package/dist/correlation.js +0 -131
- package/dist/correlation.js.map +0 -1
- package/dist/duration.d.ts +0 -27
- package/dist/duration.d.ts.map +0 -1
- package/dist/duration.js +0 -47
- package/dist/duration.js.map +0 -1
- package/dist/errors.d.ts +0 -1303
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -745
- package/dist/errors.js.map +0 -1
- package/dist/h3/_internal/client.d.ts +0 -9
- package/dist/h3/_internal/client.d.ts.map +0 -1
- package/dist/h3/_internal/client.js +0 -652
- package/dist/h3/_internal/client.js.map +0 -1
- package/dist/h3/_internal/contracts.d.ts.map +0 -1
- package/dist/h3/_internal/contracts.js +0 -123
- package/dist/h3/_internal/contracts.js.map +0 -1
- package/dist/h3/_internal/deployment.d.ts +0 -8
- package/dist/h3/_internal/deployment.d.ts.map +0 -1
- package/dist/h3/_internal/deployment.js +0 -149
- package/dist/h3/_internal/deployment.js.map +0 -1
- package/dist/h3/_internal/evidence.d.ts +0 -17
- package/dist/h3/_internal/evidence.d.ts.map +0 -1
- package/dist/h3/_internal/evidence.js +0 -48
- package/dist/h3/_internal/evidence.js.map +0 -1
- package/dist/h3/_internal/operations.d.ts +0 -58
- package/dist/h3/_internal/operations.d.ts.map +0 -1
- package/dist/h3/_internal/operations.js +0 -268
- package/dist/h3/_internal/operations.js.map +0 -1
- package/dist/h3/_internal/references.d.ts +0 -24
- package/dist/h3/_internal/references.d.ts.map +0 -1
- package/dist/h3/_internal/references.js +0 -309
- package/dist/h3/_internal/references.js.map +0 -1
- package/dist/h3/_internal/request.d.ts +0 -21
- package/dist/h3/_internal/request.d.ts.map +0 -1
- package/dist/h3/_internal/request.js +0 -97
- package/dist/h3/_internal/request.js.map +0 -1
- package/dist/h3/_internal/retained.d.ts +0 -22
- package/dist/h3/_internal/retained.d.ts.map +0 -1
- package/dist/h3/_internal/retained.js +0 -89
- package/dist/h3/_internal/retained.js.map +0 -1
- package/dist/h3/_internal/state.d.ts +0 -44
- package/dist/h3/_internal/state.d.ts.map +0 -1
- package/dist/h3/_internal/state.js +0 -283
- package/dist/h3/_internal/state.js.map +0 -1
- package/dist/h3/index.d.ts +0 -10
- package/dist/h3/index.d.ts.map +0 -1
- package/dist/h3/index.js +0 -7
- package/dist/h3/index.js.map +0 -1
- package/dist/h3/messages.d.ts.map +0 -1
- package/dist/h3/messages.js.map +0 -1
- package/dist/h3/profile.d.ts.map +0 -1
- package/dist/h3/profile.js.map +0 -1
- package/dist/h3/types.d.ts +0 -291
- package/dist/h3/types.d.ts.map +0 -1
- package/dist/h3/types.js +0 -2
- package/dist/h3/types.js.map +0 -1
- package/dist/host.d.ts +0 -26
- package/dist/host.d.ts.map +0 -1
- package/dist/host.js +0 -20
- package/dist/host.js.map +0 -1
- package/dist/json.d.ts +0 -25
- package/dist/json.d.ts.map +0 -1
- package/dist/json.js +0 -143
- package/dist/json.js.map +0 -1
- package/dist/media-stream.d.ts +0 -10
- package/dist/media-stream.d.ts.map +0 -1
- package/dist/media-stream.js +0 -18
- package/dist/media-stream.js.map +0 -1
- package/dist/observation.d.ts +0 -38
- package/dist/observation.d.ts.map +0 -1
- package/dist/observation.js +0 -113
- package/dist/observation.js.map +0 -1
- package/dist/orchestration/elapsed.d.ts +0 -9
- package/dist/orchestration/elapsed.d.ts.map +0 -1
- package/dist/orchestration/elapsed.js +0 -8
- package/dist/orchestration/elapsed.js.map +0 -1
- package/dist/orchestration/h3-source.d.ts +0 -58
- package/dist/orchestration/h3-source.d.ts.map +0 -1
- package/dist/orchestration/h3-source.js +0 -362
- package/dist/orchestration/h3-source.js.map +0 -1
- package/dist/orchestration/index.d.ts +0 -17
- package/dist/orchestration/index.d.ts.map +0 -1
- package/dist/orchestration/index.js +0 -12
- package/dist/orchestration/index.js.map +0 -1
- package/dist/orchestration/media-buffer.d.ts +0 -32
- package/dist/orchestration/media-buffer.d.ts.map +0 -1
- package/dist/orchestration/media-buffer.js +0 -94
- package/dist/orchestration/media-buffer.js.map +0 -1
- package/dist/orchestration/open-h3.d.ts +0 -101
- package/dist/orchestration/open-h3.d.ts.map +0 -1
- package/dist/orchestration/open-h3.js +0 -127
- package/dist/orchestration/open-h3.js.map +0 -1
- package/dist/orchestration/queries.d.ts +0 -10
- package/dist/orchestration/queries.d.ts.map +0 -1
- package/dist/orchestration/queries.js +0 -43
- package/dist/orchestration/queries.js.map +0 -1
- package/dist/orchestration/references.d.ts +0 -18
- package/dist/orchestration/references.d.ts.map +0 -1
- package/dist/orchestration/references.js +0 -79
- package/dist/orchestration/references.js.map +0 -1
- package/dist/orchestration/renewal-state.d.ts +0 -84
- package/dist/orchestration/renewal-state.d.ts.map +0 -1
- package/dist/orchestration/renewal-state.js +0 -95
- package/dist/orchestration/renewal-state.js.map +0 -1
- package/dist/orchestration/renewal.d.ts +0 -103
- package/dist/orchestration/renewal.d.ts.map +0 -1
- package/dist/orchestration/renewal.js +0 -1088
- package/dist/orchestration/renewal.js.map +0 -1
- package/dist/orchestration/request.d.ts +0 -65
- package/dist/orchestration/request.d.ts.map +0 -1
- package/dist/orchestration/request.js +0 -134
- package/dist/orchestration/request.js.map +0 -1
- package/dist/orchestration/retention.d.ts +0 -100
- package/dist/orchestration/retention.d.ts.map +0 -1
- package/dist/orchestration/retention.js +0 -134
- package/dist/orchestration/retention.js.map +0 -1
- package/dist/orchestration/routing.d.ts +0 -21
- package/dist/orchestration/routing.d.ts.map +0 -1
- package/dist/orchestration/routing.js +0 -93
- package/dist/orchestration/routing.js.map +0 -1
- package/dist/orchestration/scheduler-key.d.ts +0 -14
- package/dist/orchestration/scheduler-key.d.ts.map +0 -1
- package/dist/orchestration/scheduler-key.js +0 -62
- package/dist/orchestration/scheduler-key.js.map +0 -1
- package/dist/orchestration/scheduler-policy.d.ts +0 -222
- package/dist/orchestration/scheduler-policy.d.ts.map +0 -1
- package/dist/orchestration/scheduler-policy.js +0 -582
- package/dist/orchestration/scheduler-policy.js.map +0 -1
- package/dist/orchestration/scheduler.d.ts +0 -383
- package/dist/orchestration/scheduler.d.ts.map +0 -1
- package/dist/orchestration/scheduler.js +0 -2597
- package/dist/orchestration/scheduler.js.map +0 -1
- package/dist/orchestration/source-slot.d.ts +0 -124
- package/dist/orchestration/source-slot.d.ts.map +0 -1
- package/dist/orchestration/source-slot.js +0 -348
- package/dist/orchestration/source-slot.js.map +0 -1
- package/dist/orchestration/types.d.ts +0 -1462
- package/dist/orchestration/types.d.ts.map +0 -1
- package/dist/orchestration/types.js +0 -135
- package/dist/orchestration/types.js.map +0 -1
- package/dist/protobuf.d.ts +0 -63
- package/dist/protobuf.d.ts.map +0 -1
- package/dist/protobuf.js +0 -252
- package/dist/protobuf.js.map +0 -1
- package/dist/session/_internal/acquire.d.ts +0 -21
- package/dist/session/_internal/acquire.d.ts.map +0 -1
- package/dist/session/_internal/acquire.js +0 -168
- package/dist/session/_internal/acquire.js.map +0 -1
- package/dist/session/_internal/cleanup.d.ts +0 -19
- package/dist/session/_internal/cleanup.d.ts.map +0 -1
- package/dist/session/_internal/cleanup.js +0 -102
- package/dist/session/_internal/cleanup.js.map +0 -1
- package/dist/session/_internal/lifecycle.d.ts +0 -67
- package/dist/session/_internal/lifecycle.d.ts.map +0 -1
- package/dist/session/_internal/lifecycle.js +0 -142
- package/dist/session/_internal/lifecycle.js.map +0 -1
- package/dist/session/_internal/remote.d.ts +0 -28
- package/dist/session/_internal/remote.d.ts.map +0 -1
- package/dist/session/_internal/remote.js +0 -71
- package/dist/session/_internal/remote.js.map +0 -1
- package/dist/session/_internal/uploads.d.ts +0 -7
- package/dist/session/_internal/uploads.d.ts.map +0 -1
- package/dist/session/_internal/uploads.js +0 -31
- package/dist/session/_internal/uploads.js.map +0 -1
- package/dist/session/commands.d.ts +0 -2
- package/dist/session/commands.d.ts.map +0 -1
- package/dist/session/commands.js +0 -2
- package/dist/session/commands.js.map +0 -1
- package/dist/session/files.d.ts +0 -21
- package/dist/session/files.d.ts.map +0 -1
- package/dist/session/files.js +0 -49
- package/dist/session/files.js.map +0 -1
- package/dist/session/index.d.ts +0 -84
- package/dist/session/index.d.ts.map +0 -1
- package/dist/session/index.js +0 -16
- package/dist/session/index.js.map +0 -1
- package/dist/session/media.d.ts +0 -114
- package/dist/session/media.d.ts.map +0 -1
- package/dist/session/media.js +0 -2
- package/dist/session/media.js.map +0 -1
- package/dist/session/recorder.d.ts +0 -24
- package/dist/session/recorder.d.ts.map +0 -1
- package/dist/session/recorder.js +0 -19
- package/dist/session/recorder.js.map +0 -1
- package/dist/session.d.ts +0 -105
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js +0 -976
- package/dist/session.js.map +0 -1
- package/dist/simulation/_internal/source.d.ts +0 -12
- package/dist/simulation/_internal/source.d.ts.map +0 -1
- package/dist/simulation/_internal/source.js +0 -513
- package/dist/simulation/_internal/source.js.map +0 -1
- package/dist/simulation/index.d.ts +0 -13
- package/dist/simulation/index.d.ts.map +0 -1
- package/dist/simulation/index.js +0 -12
- package/dist/simulation/index.js.map +0 -1
- package/dist/simulation/types.d.ts +0 -39
- package/dist/simulation/types.d.ts.map +0 -1
- package/dist/simulation/types.js +0 -2
- package/dist/simulation/types.js.map +0 -1
- package/dist/stats.d.ts.map +0 -1
- package/dist/stats.js +0 -205
- package/dist/stats.js.map +0 -1
- package/dist/testing/Faults.d.ts +0 -4
- package/dist/testing/Faults.d.ts.map +0 -1
- package/dist/testing/Faults.js +0 -7
- package/dist/testing/Faults.js.map +0 -1
- package/dist/testing/Png.d.ts +0 -3
- package/dist/testing/Png.d.ts.map +0 -1
- package/dist/testing/Png.js.map +0 -1
- package/dist/testing/Wav.d.ts +0 -14
- package/dist/testing/Wav.d.ts.map +0 -1
- package/dist/testing/Wav.js +0 -37
- package/dist/testing/Wav.js.map +0 -1
- package/dist/testing/index.d.ts +0 -5
- package/dist/testing/index.d.ts.map +0 -1
- package/dist/testing/index.js +0 -4
- package/dist/testing/index.js.map +0 -1
- package/dist/wire.d.ts +0 -7
- package/dist/wire.d.ts.map +0 -1
- package/dist/wire.generated.d.ts +0 -305
- package/dist/wire.generated.d.ts.map +0 -1
- package/dist/wire.generated.js +0 -1257
- package/dist/wire.generated.js.map +0 -1
- package/dist/wire.js +0 -5
- package/dist/wire.js.map +0 -1
|
@@ -0,0 +1,2338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playout's pure policy: one state record and `step`, which applies one
|
|
3
|
+
* input and returns the new state, what to do, and when to be woken if nothing
|
|
4
|
+
* happens first. It holds no Effect, reads no clock and performs no I/O; the
|
|
5
|
+
* runtime supplies monotonic and wall time with every input.
|
|
6
|
+
*
|
|
7
|
+
* The plan is the single source of truth. Each session only executes its window
|
|
8
|
+
* of it: new work goes to the newest live session, Ready clips are ordered
|
|
9
|
+
* within their own session and never ranked across two, and each session has
|
|
10
|
+
* at most one provider command in flight, so a refusal is always attributable.
|
|
11
|
+
* Sessions' commands run side by side; what must follow across sessions, a
|
|
12
|
+
* switch or the removal of the retiring session's filler, waits on what the
|
|
13
|
+
* plan has seen, never on another session's command.
|
|
14
|
+
*/
|
|
15
|
+
import { dual } from "effect/Function";
|
|
16
|
+
import { requestSeconds } from "../h3/profile.js";
|
|
17
|
+
/**
|
|
18
|
+
* Whether a command was refused unsent because its session was not in a state
|
|
19
|
+
* to take it, as when its connection drops before its source can say so. Only a
|
|
20
|
+
* change in what the session reports can mend that, so it waits for one.
|
|
21
|
+
*/
|
|
22
|
+
const unready = (cause) => cause.context.outcome === "not-submitted" && cause.reason._tag === "InvalidState";
|
|
23
|
+
/** Whether a command that failed may have taken effect unseen. */
|
|
24
|
+
const uncertain = (result) => result._tag === "Died" || result.cause.context.outcome === "unknown";
|
|
25
|
+
export const initial = {
|
|
26
|
+
items: new Map(),
|
|
27
|
+
groups: new Map(),
|
|
28
|
+
settled: [],
|
|
29
|
+
nextOrder: 1,
|
|
30
|
+
sessions: [],
|
|
31
|
+
air: undefined,
|
|
32
|
+
opening: false,
|
|
33
|
+
openRetryAt: 0,
|
|
34
|
+
openingPaused: false,
|
|
35
|
+
setupFailures: 0,
|
|
36
|
+
countedFailures: 0,
|
|
37
|
+
moderations: 0,
|
|
38
|
+
nextCommand: 1,
|
|
39
|
+
filler: {
|
|
40
|
+
index: 0,
|
|
41
|
+
retries: [],
|
|
42
|
+
flagged: [],
|
|
43
|
+
retryAt: 0,
|
|
44
|
+
refilling: false,
|
|
45
|
+
},
|
|
46
|
+
batches: [],
|
|
47
|
+
joins: new Map(),
|
|
48
|
+
drains: [],
|
|
49
|
+
accepting: true,
|
|
50
|
+
closed: false,
|
|
51
|
+
samples: { build: [], continued: [], length: [] },
|
|
52
|
+
cut: undefined,
|
|
53
|
+
cutting: undefined,
|
|
54
|
+
starving: false,
|
|
55
|
+
starved: 0,
|
|
56
|
+
};
|
|
57
|
+
const retryDelayMs = 1_000;
|
|
58
|
+
/**
|
|
59
|
+
* The wait for the next open after `consecutive` failed setups in a row: a second for each, but
|
|
60
|
+
* no more than for `maxSetupFailures` of them, and at least one.
|
|
61
|
+
*/
|
|
62
|
+
const setupDelayMs = (config, consecutive) => retryDelayMs * Math.min(consecutive, Math.max(1, config.maxSetupFailures));
|
|
63
|
+
const cutMarginMs = 1_000;
|
|
64
|
+
const exposureMarginMs = 1_500;
|
|
65
|
+
const lookaheadMarginSeconds = 1;
|
|
66
|
+
/**
|
|
67
|
+
* Looks one step takes at most. None in the property's scripts decided anything past its
|
|
68
|
+
* second look: the bound only keeps a fault from spinning the loop.
|
|
69
|
+
*/
|
|
70
|
+
const maxLooks = 16;
|
|
71
|
+
const maxSamples = 32;
|
|
72
|
+
const minimumSamples = 3;
|
|
73
|
+
const quantile = (values, q) => {
|
|
74
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
75
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
|
|
76
|
+
};
|
|
77
|
+
const spreadOf = (samples) => samples.length < minimumSamples
|
|
78
|
+
? undefined
|
|
79
|
+
: { median: quantile(samples, 0.5), p95: quantile(samples, 0.95) };
|
|
80
|
+
const estimatesOf = (samples) => ({
|
|
81
|
+
build: spreadOf(samples.build),
|
|
82
|
+
continuedBuild: spreadOf(samples.continued),
|
|
83
|
+
length: samples.length.length === 0 ? 1 : quantile(samples.length, 0.5),
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* Until a continued build is measured, one is projected at this multiple of an
|
|
87
|
+
* independent build: on hosted H3 a continued 5 s clip took 5.45 s to build,
|
|
88
|
+
* against 2.1 to 2.2 s for independent ones (0.7.0 scheduler-edits run).
|
|
89
|
+
*/
|
|
90
|
+
const continuedBuildFactor = 2.5;
|
|
91
|
+
/**
|
|
92
|
+
* Build seconds per requested second a continued build is projected at, erring
|
|
93
|
+
* long: the p95 of measured continued builds, or their longest below three
|
|
94
|
+
* samples; failing those, the same of independent builds times
|
|
95
|
+
* `continuedBuildFactor`. Undefined before any build was measured.
|
|
96
|
+
*/
|
|
97
|
+
const continuedBuildRate = (samples) => {
|
|
98
|
+
const long = (values) => values.length >= minimumSamples ? quantile(values, 0.95) : Math.max(...values);
|
|
99
|
+
if (samples.continued.length > 0)
|
|
100
|
+
return long(samples.continued);
|
|
101
|
+
if (samples.build.length > 0)
|
|
102
|
+
return long(samples.build) * continuedBuildFactor;
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* The newest live session that can take work: new work goes there. One whose
|
|
107
|
+
* enqueue stayed unknown past the deadline takes none, since what it does with
|
|
108
|
+
* a command can no longer be told.
|
|
109
|
+
*/
|
|
110
|
+
const preferredOf = (sessions) => [...sessions]
|
|
111
|
+
.reverse()
|
|
112
|
+
.find((value) => !value.retiring && !value.indeterminate && value.source?.available === true);
|
|
113
|
+
/**
|
|
114
|
+
* When `value`'s playing clip ends, counted from its observed start. Undefined for a clip of
|
|
115
|
+
* unknown length or whose start was not seen: what is left of it does not fall with the time.
|
|
116
|
+
*/
|
|
117
|
+
const playingEndOf = (value) => {
|
|
118
|
+
const playing = value?.source?.playing;
|
|
119
|
+
const observed = value?.playing;
|
|
120
|
+
if (playing?.seconds === undefined || observed?.clipId !== playing.clipId)
|
|
121
|
+
return undefined;
|
|
122
|
+
return observed.at + playing.seconds * 1000;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* What is left of `value`'s playing clip at `mono`, counted from its observed start. A clip
|
|
126
|
+
* of unknown length may end at any moment, so nothing is counted for it and the plan builds ahead.
|
|
127
|
+
*/
|
|
128
|
+
const playingRestOf = (value, mono) => {
|
|
129
|
+
const playing = value?.source?.playing;
|
|
130
|
+
if (playing?.seconds === undefined)
|
|
131
|
+
return 0;
|
|
132
|
+
const end = playingEndOf(value);
|
|
133
|
+
return end === undefined ? playing.seconds * 1000 : Math.max(0, end - mono);
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Whether a Ready clip airs when its turn comes: it isn't held, waiting on its
|
|
137
|
+
* batch or an anchor still ahead, or withdrawn. A clip that is no item's airs.
|
|
138
|
+
*/
|
|
139
|
+
const airsOf = (items, clip, now) => {
|
|
140
|
+
const item = clip.tag?._tag === "Item" ? items.get(clip.tag.key) : undefined;
|
|
141
|
+
if (item === undefined)
|
|
142
|
+
return true;
|
|
143
|
+
const at = item.spec.start._tag === "At" ? now.mono + (item.spec.start.time - now.wall) : undefined;
|
|
144
|
+
return (item.mode !== "held" &&
|
|
145
|
+
item.batch === undefined &&
|
|
146
|
+
(at === undefined || at <= now.mono) &&
|
|
147
|
+
item.withdraw === undefined);
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Seconds of air secured: the playing clip's rest, then the Ready clips that
|
|
151
|
+
* air from the session on air and from its replacement once that takes over.
|
|
152
|
+
*/
|
|
153
|
+
const securedOf = (state, now) => {
|
|
154
|
+
const air = state.sessions.find((value) => value.id === state.air);
|
|
155
|
+
const replacement = state.sessions.find((value) => value.id !== state.air && !value.retiring);
|
|
156
|
+
const ready = (value) => (value?.source?.ready ?? [])
|
|
157
|
+
.filter((clip) => airsOf(state.items, clip, now))
|
|
158
|
+
.reduce((total, clip) => total + clip.seconds, 0);
|
|
159
|
+
return playingRestOf(air, now.mono) / 1000 + ready(air) + ready(replacement);
|
|
160
|
+
};
|
|
161
|
+
const compareRank = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3];
|
|
162
|
+
/** A group's answer from its parts': `withdrawn` if any was, else `already-started` if any started. */
|
|
163
|
+
const combined = (outcomes) => {
|
|
164
|
+
if (outcomes.includes("withdrawn"))
|
|
165
|
+
return "withdrawn";
|
|
166
|
+
return outcomes.includes("already-started") ? "already-started" : "not-found";
|
|
167
|
+
};
|
|
168
|
+
/** What a withdrawal finds of an item from its record: dropped, started, or neither. */
|
|
169
|
+
const fateOf = (item) => {
|
|
170
|
+
if (item.status?._tag === "Dropped")
|
|
171
|
+
return "withdrawn";
|
|
172
|
+
const started = item.startedAt !== undefined || item.phase === "Started" || item.status?._tag === "Ended";
|
|
173
|
+
return started ? "already-started" : "not-found";
|
|
174
|
+
};
|
|
175
|
+
/** One look at the plan: applies the input, then decides what is due at `now`. */
|
|
176
|
+
const decide = (config, previous, input, now) => {
|
|
177
|
+
const items = new Map(previous.items);
|
|
178
|
+
const groups = new Map(previous.groups);
|
|
179
|
+
let state = previous;
|
|
180
|
+
const actions = [];
|
|
181
|
+
const lanes = config.lanes.length;
|
|
182
|
+
const set = (key, patch) => {
|
|
183
|
+
const next = { ...items.get(key), ...patch };
|
|
184
|
+
items.set(key, next);
|
|
185
|
+
return next;
|
|
186
|
+
};
|
|
187
|
+
const emit = (event) => {
|
|
188
|
+
actions.push({ _tag: "Emit", event });
|
|
189
|
+
};
|
|
190
|
+
const asRun = (key, status) => {
|
|
191
|
+
set(key, { status });
|
|
192
|
+
emit({ _tag: "AsRun", event: { key, at: now.wall, status } });
|
|
193
|
+
};
|
|
194
|
+
const session = (id) => state.sessions.find((candidate) => candidate.id === id);
|
|
195
|
+
const updateSession = (id, patch) => {
|
|
196
|
+
state = {
|
|
197
|
+
...state,
|
|
198
|
+
sessions: state.sessions.map((value) => (value.id === id ? { ...value, ...patch } : value)),
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
/** Records what a session's source reports, counting each change in whether it takes commands. */
|
|
202
|
+
const report = (id, source) => {
|
|
203
|
+
const previous = session(id);
|
|
204
|
+
const changed = (previous?.source?.available ?? false) !== source.available;
|
|
205
|
+
updateSession(id, { source, changes: (previous?.changes ?? 0) + (changed ? 1 : 0) });
|
|
206
|
+
};
|
|
207
|
+
/** The session new work goes to: the newest one that is live and not retiring. */
|
|
208
|
+
const preferred = () => preferredOf(state.sessions);
|
|
209
|
+
const atMono = (item) => item.spec.start._tag === "At" ? now.mono + (item.spec.start.time - now.wall) : undefined;
|
|
210
|
+
/**
|
|
211
|
+
* Answers a withdrawal waiting on one item. A group key's withdrawal waits on
|
|
212
|
+
* each part and answers once all have: `withdrawn` if any part was, else
|
|
213
|
+
* `already-started` if any started, as 0.7.0 answered it.
|
|
214
|
+
*/
|
|
215
|
+
const answer = (waiter, outcome) => {
|
|
216
|
+
const id = `${String(waiter.id)}:${String(waiter.index)}`;
|
|
217
|
+
const join = state.joins.get(id);
|
|
218
|
+
if (join === undefined) {
|
|
219
|
+
actions.push({ _tag: "Withdrawn", id: waiter.id, index: waiter.index, outcome });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const outcomes = [...join.outcomes, outcome];
|
|
223
|
+
const joins = new Map(state.joins);
|
|
224
|
+
if (join.left > 1)
|
|
225
|
+
joins.set(id, { left: join.left - 1, outcomes });
|
|
226
|
+
else
|
|
227
|
+
joins.delete(id);
|
|
228
|
+
state = { ...state, joins };
|
|
229
|
+
if (join.left > 1)
|
|
230
|
+
return;
|
|
231
|
+
actions.push({
|
|
232
|
+
_tag: "Withdrawn",
|
|
233
|
+
id: waiter.id,
|
|
234
|
+
index: waiter.index,
|
|
235
|
+
outcome: combined(outcomes),
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
/** Settles an item for good, resolving withdrawals that wait on it and recording history. */
|
|
239
|
+
const settle = (key, status) => {
|
|
240
|
+
const item = items.get(key);
|
|
241
|
+
if (item === undefined || item.phase === "Settled")
|
|
242
|
+
return;
|
|
243
|
+
// The as-run goes out first, so a withdrawal's caller finds it already published.
|
|
244
|
+
set(key, { phase: "Settled", waiting: [], withdraw: undefined });
|
|
245
|
+
asRun(key, status);
|
|
246
|
+
const outcome = fateOf({ ...item, status });
|
|
247
|
+
for (const wait of item.waiting)
|
|
248
|
+
answer(wait, outcome);
|
|
249
|
+
state = { ...state, settled: [...state.settled, key] };
|
|
250
|
+
// A part that fails or is dropped takes the parts after it with it; a replaced one does not,
|
|
251
|
+
// since its replacement takes its place.
|
|
252
|
+
if (item.group !== undefined &&
|
|
253
|
+
!item.inserted &&
|
|
254
|
+
(status._tag === "Failed" || (status._tag === "Dropped" && status.reason !== "replaced")))
|
|
255
|
+
for (const part of groups.get(item.group.key)?.parts ?? []) {
|
|
256
|
+
const other = items.get(part);
|
|
257
|
+
if (other?.group !== undefined && other.group.index > item.group.index)
|
|
258
|
+
withdraw(part, "withdrawn");
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* Withdraws an item the plan no longer wants. What has no clip goes at once;
|
|
263
|
+
* a clip is removed from its provider first, and a started one cannot go.
|
|
264
|
+
*/
|
|
265
|
+
const withdraw = (key, reason, waiter) => {
|
|
266
|
+
const item = items.get(key);
|
|
267
|
+
if (item === undefined) {
|
|
268
|
+
if (waiter !== undefined)
|
|
269
|
+
answer(waiter, "not-found");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (item.phase === "Started" || (item.phase === "Settled" && item.startedAt !== undefined)) {
|
|
273
|
+
if (waiter !== undefined)
|
|
274
|
+
answer(waiter, "already-started");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (item.phase === "Settled") {
|
|
278
|
+
if (waiter !== undefined)
|
|
279
|
+
answer(waiter, item.status?._tag === "Dropped" ? "withdrawn" : "not-found");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const waiting = waiter === undefined ? item.waiting : [...item.waiting, waiter];
|
|
283
|
+
if (item.phase === "Accepted") {
|
|
284
|
+
set(key, { waiting });
|
|
285
|
+
settle(key, { _tag: "Dropped", reason });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
set(key, { withdraw: item.withdraw ?? reason, waiting });
|
|
289
|
+
};
|
|
290
|
+
const live = (item) => item !== undefined && item.phase !== "Settled";
|
|
291
|
+
const readyOf = (value) => value.source?.ready ?? [];
|
|
292
|
+
/** Whether a session still holds the air: it takes new work, or has clips of its own to air. */
|
|
293
|
+
const holding = (value) => value !== undefined &&
|
|
294
|
+
(!value.indeterminate || value.source?.playing !== undefined || readyOf(value).length > 0);
|
|
295
|
+
const itemOf = (clip) => clip?.tag?._tag === "Item" ? items.get(clip.tag.key) : undefined;
|
|
296
|
+
// Ranks: lexicographic places in a session's Ready order and in the build order.
|
|
297
|
+
const superseded = new Set(state.batches.flatMap((batch) => batch.targets.map((target) => target.key)));
|
|
298
|
+
const begun = (item) => item.group !== undefined &&
|
|
299
|
+
(groups.get(item.group.key)?.parts ?? []).some((part) => {
|
|
300
|
+
const other = items.get(part);
|
|
301
|
+
return other?.startedAt !== undefined || other?.phase === "Started";
|
|
302
|
+
});
|
|
303
|
+
/** The item still in the place a replacement takes; replaced replacements lead back to it. */
|
|
304
|
+
const replacedOf = (item) => {
|
|
305
|
+
let old = item.replaces === undefined ? undefined : items.get(item.replaces);
|
|
306
|
+
while (old?.phase === "Settled" && old.startedAt === undefined && old.replaces !== undefined)
|
|
307
|
+
old = items.get(old.replaces);
|
|
308
|
+
return old;
|
|
309
|
+
};
|
|
310
|
+
const rankItem = (item) => {
|
|
311
|
+
const at = atMono(item);
|
|
312
|
+
if (item.mode === "held" || (at !== undefined && now.mono < at) || item.batch !== undefined)
|
|
313
|
+
return [lanes + 1, 1, item.order, item.generation];
|
|
314
|
+
// Its build continues the Ready clip it follows on its session, so it airs right behind that
|
|
315
|
+
// clip until that clip starts. An item accepted again for a rebuild follows nothing yet.
|
|
316
|
+
const followed = item.follows === undefined || item.phase === "Accepted"
|
|
317
|
+
? undefined
|
|
318
|
+
: session(item.sessionId)?.source?.ready.find((clip) => clip.clipId === item.follows);
|
|
319
|
+
if (followed !== undefined) {
|
|
320
|
+
const behind = rankClip(followed);
|
|
321
|
+
return [behind[0], behind[1], behind[2], behind[3] + 0.5];
|
|
322
|
+
}
|
|
323
|
+
if (item.mode === "asap")
|
|
324
|
+
return [-0.5, 0, item.order, item.generation];
|
|
325
|
+
const displaced = [...items.values()].some((other) => (other.phase === "Ready" || other.phase === "Started") &&
|
|
326
|
+
other.batch === undefined &&
|
|
327
|
+
replacedOf(other)?.spec.key === item.spec.key);
|
|
328
|
+
return [
|
|
329
|
+
item.spec.lane,
|
|
330
|
+
superseded.has(item.spec.key) ? 2 : begun(item) ? 0 : 1,
|
|
331
|
+
item.order,
|
|
332
|
+
displaced ? Infinity : item.generation,
|
|
333
|
+
];
|
|
334
|
+
};
|
|
335
|
+
const rankClip = (clip) => {
|
|
336
|
+
if (clip.tag === undefined)
|
|
337
|
+
return [-1, 0, 0, 0];
|
|
338
|
+
if (clip.tag._tag === "Filler")
|
|
339
|
+
return [lanes, 1, clip.tag.index, 0];
|
|
340
|
+
const item = items.get(clip.tag.key);
|
|
341
|
+
return item === undefined ? [-1, 0, 0, 0] : rankItem(item);
|
|
342
|
+
};
|
|
343
|
+
/** Which of two waiting items takes the build slot first. */
|
|
344
|
+
const buildOrder = (a, b) => Number(b.mode === "asap") - Number(a.mode === "asap") ||
|
|
345
|
+
Number(a.mode === "held") - Number(b.mode === "held") ||
|
|
346
|
+
a.spec.lane - b.spec.lane ||
|
|
347
|
+
Number(b.replaces !== undefined) - Number(a.replaces !== undefined) ||
|
|
348
|
+
Number(b.group !== undefined && b.group.index > 0) -
|
|
349
|
+
Number(a.group !== undefined && a.group.index > 0) ||
|
|
350
|
+
(a.startBy ?? Infinity) - (b.startBy ?? Infinity) ||
|
|
351
|
+
a.order - b.order;
|
|
352
|
+
/** A group's part may build only once the part before it was admitted. */
|
|
353
|
+
const previousAdmitted = (item) => {
|
|
354
|
+
// An insert waits for whatever airs just before it in its lane to be admitted.
|
|
355
|
+
if (item.inserted) {
|
|
356
|
+
const before = [...items.values()]
|
|
357
|
+
.filter((other) => live(other) && other.spec.lane === item.spec.lane && other.order < item.order)
|
|
358
|
+
.sort((a, b) => b.order - a.order)[0];
|
|
359
|
+
return before?.phase !== "Accepted";
|
|
360
|
+
}
|
|
361
|
+
if (item.group === undefined)
|
|
362
|
+
return true;
|
|
363
|
+
const index = item.group.index;
|
|
364
|
+
return !(groups.get(item.group.key)?.parts ?? []).some((part) => {
|
|
365
|
+
const other = items.get(part);
|
|
366
|
+
return (other?.group !== undefined &&
|
|
367
|
+
!other.inserted &&
|
|
368
|
+
other.group.index === index - 1 &&
|
|
369
|
+
other.phase === "Accepted");
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
const estimates = () => estimatesOf(state.samples);
|
|
373
|
+
const playingRestMs = (value) => playingRestOf(value, now.mono);
|
|
374
|
+
const airs = (clip) => airsOf(items, clip, now);
|
|
375
|
+
/**
|
|
376
|
+
* The runway as the time passes: at `time` it is `(Math.max(end, time) - time) / 1000 +
|
|
377
|
+
* seconds`. It falls while the clip on air plays, to that clip's end, and holds still with
|
|
378
|
+
* none, or with one whose length or start is unknown, counted as `playingRestOf` counts it.
|
|
379
|
+
*/
|
|
380
|
+
const runwayTerms = () => {
|
|
381
|
+
const target = preferred() ?? session(state.air);
|
|
382
|
+
if (target === undefined)
|
|
383
|
+
return { end: -Infinity, seconds: 0 };
|
|
384
|
+
const onAir = target.id === state.air;
|
|
385
|
+
const end = onAir ? playingEndOf(target) : undefined;
|
|
386
|
+
const ready = readyOf(target)
|
|
387
|
+
.filter(airs)
|
|
388
|
+
.reduce((total, clip) => total + clip.seconds, 0);
|
|
389
|
+
return end === undefined
|
|
390
|
+
? { end: -Infinity, seconds: (onAir ? playingRestMs(target) / 1000 : 0) + ready }
|
|
391
|
+
: { end, seconds: ready };
|
|
392
|
+
};
|
|
393
|
+
/** Seconds of air secured on the session that takes new work. */
|
|
394
|
+
const runway = () => {
|
|
395
|
+
const { end, seconds } = runwayTerms();
|
|
396
|
+
return (Math.max(end, now.mono) - now.mono) / 1000 + seconds;
|
|
397
|
+
};
|
|
398
|
+
/** When the runway falls to `seconds` as the clip on air plays; undefined if it doesn't. */
|
|
399
|
+
const fallsTo = (seconds) => {
|
|
400
|
+
const { end, seconds: after } = runwayTerms();
|
|
401
|
+
return Number.isFinite(end) && seconds > after ? end - (seconds - after) * 1000 : undefined;
|
|
402
|
+
};
|
|
403
|
+
/** Whether the runway is below `seconds`: it is from the instant it falls there. */
|
|
404
|
+
const below = (seconds) => runway() < seconds || now.mono >= (fallsTo(seconds) ?? Infinity);
|
|
405
|
+
/**
|
|
406
|
+
* The earliest a clip in `item`'s place could start, optimistically, as the time passes: at
|
|
407
|
+
* `time` it is `Math.max(from, time + after)`. The end of the clip on air and the builds in
|
|
408
|
+
* flight fix `from`; the air ahead and the builds it waits for add `after` to the time, as
|
|
409
|
+
* they do once that clip is over.
|
|
410
|
+
*/
|
|
411
|
+
const projection = (item) => {
|
|
412
|
+
const target = preferred() ?? session(state.air);
|
|
413
|
+
const rank = rankItem(item);
|
|
414
|
+
const aheadMs = (target === undefined ? [] : readyOf(target))
|
|
415
|
+
.filter((clip) => compareRank(rankClip(clip), rank) < 0 && itemOf(clip)?.withdraw === undefined)
|
|
416
|
+
.reduce((total, clip) => total + clip.seconds * 1000, 0);
|
|
417
|
+
const air = session(state.air);
|
|
418
|
+
const end = playingEndOf(air);
|
|
419
|
+
const playable = {
|
|
420
|
+
from: (end ?? -Infinity) + aheadMs,
|
|
421
|
+
after: (end === undefined ? playingRestMs(air) : 0) + aheadMs,
|
|
422
|
+
};
|
|
423
|
+
const perSecond = estimates().build?.median;
|
|
424
|
+
if (perSecond === undefined)
|
|
425
|
+
return playable;
|
|
426
|
+
const buildMs = (seconds) => seconds * perSecond * 1000;
|
|
427
|
+
const inFlight = [
|
|
428
|
+
...[...items.values()].flatMap((other) => other.phase === "Building" &&
|
|
429
|
+
other.dispatchedAt !== undefined &&
|
|
430
|
+
other.spec.key !== item.spec.key
|
|
431
|
+
? [other.dispatchedAt + buildMs(other.spec.seconds)]
|
|
432
|
+
: []),
|
|
433
|
+
...(target?.fillerSent === undefined
|
|
434
|
+
? []
|
|
435
|
+
: [target.fillerSent.at + buildMs(target.fillerSent.seconds)]),
|
|
436
|
+
];
|
|
437
|
+
const first = [...items.values()]
|
|
438
|
+
.filter((other) => other.phase === "Accepted" &&
|
|
439
|
+
other.spec.key !== item.spec.key &&
|
|
440
|
+
other.withdraw === undefined &&
|
|
441
|
+
other.mode !== "held" &&
|
|
442
|
+
(atMono(other) ?? -Infinity) <= now.mono &&
|
|
443
|
+
(other.notBefore ?? -Infinity) <= now.mono &&
|
|
444
|
+
previousAdmitted(other) &&
|
|
445
|
+
buildOrder(other, item) < 0)
|
|
446
|
+
.reduce((total, other) => total + buildMs(other.spec.seconds), 0);
|
|
447
|
+
const waits = first + buildMs(item.spec.seconds);
|
|
448
|
+
return {
|
|
449
|
+
from: Math.max(playable.from, Math.max(...inFlight) + waits),
|
|
450
|
+
after: Math.max(playable.after, waits),
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
/**
|
|
454
|
+
* Whether a clip in `item`'s place could not start before `startBy`, as projected. Once
|
|
455
|
+
* nothing holds it, the projection grows with the time: it misses from `startBy - after` on.
|
|
456
|
+
*/
|
|
457
|
+
const misses = (item, startBy) => {
|
|
458
|
+
const { from, after } = projection(item);
|
|
459
|
+
return Math.max(from, now.mono + after) >= startBy || now.mono >= startBy - after;
|
|
460
|
+
};
|
|
461
|
+
/** Whether `item` is firm, not sent, and dropped once projected to miss its `startBy`. */
|
|
462
|
+
const lateWhenProjected = (item) => item.phase === "Accepted" &&
|
|
463
|
+
item.spec.window?.firm === true &&
|
|
464
|
+
item.startBy !== undefined &&
|
|
465
|
+
item.dispatchedAt === undefined &&
|
|
466
|
+
estimates().build !== undefined;
|
|
467
|
+
const newItem = (spec, place = {}) => ({
|
|
468
|
+
spec,
|
|
469
|
+
order: state.nextOrder,
|
|
470
|
+
generation: 0,
|
|
471
|
+
inserted: false,
|
|
472
|
+
mode: spec.start._tag === "Asap" ? "asap" : spec.start._tag === "Manual" ? "held" : "follow",
|
|
473
|
+
phase: "Accepted",
|
|
474
|
+
notBefore: spec.window?.notBeforeMs === undefined ? undefined : now.mono + spec.window.notBeforeMs,
|
|
475
|
+
startBy: spec.window?.startByMs === undefined ? undefined : now.mono + spec.window.startByMs,
|
|
476
|
+
everUnknown: false,
|
|
477
|
+
unbuiltLosses: 0,
|
|
478
|
+
fired: [],
|
|
479
|
+
waiting: [],
|
|
480
|
+
...place,
|
|
481
|
+
});
|
|
482
|
+
const applyEdit = (id, edits, batched) => {
|
|
483
|
+
// A drain stops admissions, not withdrawals.
|
|
484
|
+
if (state.closed || (!state.accepting && edits.some((edit) => edit._tag !== "Withdraw"))) {
|
|
485
|
+
actions.push({ _tag: "Refused", id, refusal: { _tag: "PlayoutClosed" } });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const refuse = (refusal) => {
|
|
489
|
+
actions.push({ _tag: "Refused", id, refusal });
|
|
490
|
+
};
|
|
491
|
+
const seen = new Set();
|
|
492
|
+
// Check every edit before any takes effect.
|
|
493
|
+
for (const edit of edits) {
|
|
494
|
+
const keys = edit._tag === "SubmitGroup"
|
|
495
|
+
? [edit.key, ...edit.parts.map((part) => part.key)]
|
|
496
|
+
: edit._tag === "Withdraw"
|
|
497
|
+
? []
|
|
498
|
+
: [edit.spec.key];
|
|
499
|
+
for (const key of keys) {
|
|
500
|
+
if (seen.has(key))
|
|
501
|
+
return refuse({ _tag: "InvalidItem", key, message: "a key appears twice in one batch" });
|
|
502
|
+
seen.add(key);
|
|
503
|
+
const existing = items.get(key);
|
|
504
|
+
const fingerprint = edit._tag === "SubmitGroup" && key === edit.key
|
|
505
|
+
? edit.fingerprint
|
|
506
|
+
: edit._tag === "SubmitGroup"
|
|
507
|
+
? edit.parts.find((part) => part.key === key)?.fingerprint
|
|
508
|
+
: edit._tag === "Withdraw"
|
|
509
|
+
? undefined
|
|
510
|
+
: edit.spec.fingerprint;
|
|
511
|
+
const group = groups.get(key);
|
|
512
|
+
if ((existing !== undefined && existing.spec.fingerprint !== fingerprint) ||
|
|
513
|
+
(group !== undefined && group.fingerprint !== fingerprint) ||
|
|
514
|
+
// An insert's place is part of it; a new group cannot take another item's key as a part.
|
|
515
|
+
(edit._tag === "Insert" &&
|
|
516
|
+
existing !== undefined &&
|
|
517
|
+
(existing.anchor?.key !== edit.anchor || existing.anchor.side !== edit.side)) ||
|
|
518
|
+
(edit._tag === "SubmitGroup" &&
|
|
519
|
+
key !== edit.key &&
|
|
520
|
+
existing !== undefined &&
|
|
521
|
+
!groups.has(edit.key)))
|
|
522
|
+
return refuse({ _tag: "KeyMismatch", key: key });
|
|
523
|
+
}
|
|
524
|
+
if (edit._tag === "Insert") {
|
|
525
|
+
const anchor = items.get(edit.anchor) ?? firstOrLastPart(edit.anchor, edit.side);
|
|
526
|
+
// After the playing item is the next boundary; before it is already past.
|
|
527
|
+
if (!live(anchor) || (anchor.phase === "Started" && edit.side === "before"))
|
|
528
|
+
return refuse({
|
|
529
|
+
_tag: "InvalidItem",
|
|
530
|
+
key: edit.spec.key,
|
|
531
|
+
message: "the anchor is not waiting to air",
|
|
532
|
+
});
|
|
533
|
+
if (anchor.mode === "held")
|
|
534
|
+
return refuse({
|
|
535
|
+
_tag: "InvalidItem",
|
|
536
|
+
key: edit.spec.key,
|
|
537
|
+
message: "a held Manual item cannot anchor an insert",
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (edit._tag === "Replace" && !live(items.get(edit.key)) && !items.has(edit.spec.key))
|
|
541
|
+
return refuse({
|
|
542
|
+
_tag: "InvalidItem",
|
|
543
|
+
key: edit.key,
|
|
544
|
+
message: "no waiting item has this key",
|
|
545
|
+
});
|
|
546
|
+
if (edit._tag === "Submit" && !items.has(edit.spec.key)) {
|
|
547
|
+
const lane = config.lanes[edit.spec.lane];
|
|
548
|
+
if (lane?.conflict === "skip" &&
|
|
549
|
+
[...items.values()].some((item) => item.spec.lane === edit.spec.lane && live(item)))
|
|
550
|
+
return refuse({ _tag: "LaneBusy", key: edit.spec.key, lane: edit.spec.lane });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// An edit refused below, as an item it adds would miss its deadline, leaves the plan as it was.
|
|
554
|
+
const saved = { items: new Map(items), groups: new Map(groups), nextOrder: state.nextOrder };
|
|
555
|
+
const results = [];
|
|
556
|
+
const adds = [];
|
|
557
|
+
const targets = [];
|
|
558
|
+
/** Group-key withdrawals by position, and how many parts each waits on. */
|
|
559
|
+
const joined = [];
|
|
560
|
+
/** Withdrawals of keys the plan does not know, answered once the edit is accepted. */
|
|
561
|
+
const notFound = [];
|
|
562
|
+
const put = (item) => {
|
|
563
|
+
items.set(item.spec.key, item);
|
|
564
|
+
adds.push(item.spec.key);
|
|
565
|
+
state = { ...state, nextOrder: state.nextOrder + 1 };
|
|
566
|
+
};
|
|
567
|
+
for (const [index, edit] of edits.entries()) {
|
|
568
|
+
switch (edit._tag) {
|
|
569
|
+
case "Submit": {
|
|
570
|
+
if (!items.has(edit.spec.key)) {
|
|
571
|
+
const lane = config.lanes[edit.spec.lane];
|
|
572
|
+
if (lane?.conflict === "replace") {
|
|
573
|
+
batched = true;
|
|
574
|
+
for (const other of items.values())
|
|
575
|
+
if (other.spec.lane === edit.spec.lane &&
|
|
576
|
+
(other.phase === "Accepted" ||
|
|
577
|
+
other.phase === "Building" ||
|
|
578
|
+
other.phase === "Ready" ||
|
|
579
|
+
other.phase === "Unknown"))
|
|
580
|
+
targets.push({ key: other.spec.key, index: -1, reason: "replaced" });
|
|
581
|
+
}
|
|
582
|
+
put(newItem(edit.spec));
|
|
583
|
+
}
|
|
584
|
+
results.push({ _tag: "Added", key: edit.spec.key });
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
case "SubmitGroup": {
|
|
588
|
+
if (!groups.has(edit.key)) {
|
|
589
|
+
groups.set(edit.key, {
|
|
590
|
+
fingerprint: edit.fingerprint,
|
|
591
|
+
parts: edit.parts.map((part) => part.key),
|
|
592
|
+
});
|
|
593
|
+
edit.parts.forEach((part, partIndex) => {
|
|
594
|
+
const { window: _window, ...rest } = part;
|
|
595
|
+
put(newItem(partIndex === 0 ? part : rest, {
|
|
596
|
+
group: { key: edit.key, index: partIndex },
|
|
597
|
+
}));
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
results.push({
|
|
601
|
+
_tag: "AddedGroup",
|
|
602
|
+
key: edit.key,
|
|
603
|
+
parts: edit.parts.map((part) => part.key),
|
|
604
|
+
});
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
case "Insert": {
|
|
608
|
+
if (!items.has(edit.spec.key)) {
|
|
609
|
+
const anchor = items.get(edit.anchor) ?? firstOrLastPart(edit.anchor, edit.side);
|
|
610
|
+
const sameLane = [...items.values()]
|
|
611
|
+
.filter((other) => other.spec.lane === anchor.spec.lane && other !== anchor)
|
|
612
|
+
.map((other) => other.order);
|
|
613
|
+
const neighbour = edit.side === "before"
|
|
614
|
+
? Math.max(anchor.order - 1, ...sameLane.filter((order) => order < anchor.order))
|
|
615
|
+
: Math.min(anchor.order + 1, ...sameLane.filter((order) => order > anchor.order));
|
|
616
|
+
// A playing anchor's start is spent: the insert follows it at the next boundary.
|
|
617
|
+
const playing = anchor.phase === "Started";
|
|
618
|
+
put(newItem({
|
|
619
|
+
...edit.spec,
|
|
620
|
+
lane: anchor.spec.lane,
|
|
621
|
+
start: playing ? { _tag: "Follow" } : anchor.spec.start,
|
|
622
|
+
}, {
|
|
623
|
+
order: (anchor.order + neighbour) / 2,
|
|
624
|
+
group: anchor.group === undefined
|
|
625
|
+
? undefined
|
|
626
|
+
: { key: anchor.group.key, index: anchor.group.index },
|
|
627
|
+
inserted: true,
|
|
628
|
+
anchor: { key: edit.anchor, side: edit.side },
|
|
629
|
+
mode: playing ? "follow" : anchor.mode,
|
|
630
|
+
}));
|
|
631
|
+
state = { ...state, nextOrder: state.nextOrder - 1 };
|
|
632
|
+
}
|
|
633
|
+
results.push({ _tag: "Added", key: edit.spec.key });
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case "Replace": {
|
|
637
|
+
if (!items.has(edit.spec.key)) {
|
|
638
|
+
const old = items.get(edit.key);
|
|
639
|
+
put(newItem({ ...edit.spec, lane: old.spec.lane, start: old.spec.start }, {
|
|
640
|
+
order: old.order,
|
|
641
|
+
generation: old.generation + 1,
|
|
642
|
+
group: old.group,
|
|
643
|
+
inserted: old.inserted,
|
|
644
|
+
replaces: old.spec.key,
|
|
645
|
+
mode: old.mode,
|
|
646
|
+
startBy: old.startBy,
|
|
647
|
+
}));
|
|
648
|
+
state = { ...state, nextOrder: state.nextOrder - 1 };
|
|
649
|
+
// A replacement takes its part's place: withdrawing the group or that place reaches it.
|
|
650
|
+
const group = old.group === undefined || old.inserted ? undefined : groups.get(old.group.key);
|
|
651
|
+
if (old.group !== undefined && group !== undefined)
|
|
652
|
+
groups.set(old.group.key, { ...group, parts: [...group.parts, edit.spec.key] });
|
|
653
|
+
}
|
|
654
|
+
results.push({ _tag: "Added", key: edit.spec.key });
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case "Withdraw": {
|
|
658
|
+
const group = groups.get(edit.key);
|
|
659
|
+
const keys = group === undefined ? [edit.key] : group.parts;
|
|
660
|
+
const known = keys.some((key) => items.has(key));
|
|
661
|
+
if (!known)
|
|
662
|
+
notFound.push(index);
|
|
663
|
+
else if (group !== undefined) {
|
|
664
|
+
// A group key withdraws every part, and answers once each part has.
|
|
665
|
+
joined.push({ index, parts: group.parts.length });
|
|
666
|
+
for (const key of group.parts)
|
|
667
|
+
targets.push({ key, index, reason: "withdrawn" });
|
|
668
|
+
}
|
|
669
|
+
else if (items.get(edit.key)?.group !== undefined && !items.get(edit.key).inserted) {
|
|
670
|
+
// A part key withdraws that part and every part after it, and answers for that part.
|
|
671
|
+
const part = items.get(edit.key);
|
|
672
|
+
for (const other of groups.get(part.group.key)?.parts ?? [])
|
|
673
|
+
if ((items.get(other)?.group?.index ?? -1) >= part.group.index)
|
|
674
|
+
targets.push({
|
|
675
|
+
key: other,
|
|
676
|
+
index: other === edit.key ? index : -1,
|
|
677
|
+
reason: "withdrawn",
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
else
|
|
681
|
+
targets.push({ key: edit.key, index, reason: "withdrawn" });
|
|
682
|
+
results.push({ _tag: "Withdrawal" });
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const key of adds) {
|
|
688
|
+
const item = items.get(key);
|
|
689
|
+
if (item.spec.window?.firm === true &&
|
|
690
|
+
item.startBy !== undefined &&
|
|
691
|
+
item.group?.index !== undefined &&
|
|
692
|
+
item.group.index > 0)
|
|
693
|
+
continue;
|
|
694
|
+
if (item.spec.window?.firm === true &&
|
|
695
|
+
item.startBy !== undefined &&
|
|
696
|
+
misses(item, item.startBy)) {
|
|
697
|
+
items.clear();
|
|
698
|
+
for (const [other, value] of saved.items)
|
|
699
|
+
items.set(other, value);
|
|
700
|
+
groups.clear();
|
|
701
|
+
for (const [group, value] of saved.groups)
|
|
702
|
+
groups.set(group, value);
|
|
703
|
+
state = { ...state, nextOrder: saved.nextOrder };
|
|
704
|
+
return refuse({ _tag: "WouldMissDeadline", key });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const index of notFound)
|
|
708
|
+
actions.push({ _tag: "Withdrawn", id, index, outcome: "not-found" });
|
|
709
|
+
if (joined.length > 0) {
|
|
710
|
+
const joins = new Map(state.joins);
|
|
711
|
+
for (const join of joined)
|
|
712
|
+
joins.set(`${String(id)}:${String(join.index)}`, { left: join.parts, outcomes: [] });
|
|
713
|
+
state = { ...state, joins };
|
|
714
|
+
}
|
|
715
|
+
const pending = batched && (adds.length > 0 || targets.length > 0);
|
|
716
|
+
for (const key of adds) {
|
|
717
|
+
set(key, { batch: pending && batched ? id : undefined });
|
|
718
|
+
asRun(key, { _tag: "Accepted" });
|
|
719
|
+
}
|
|
720
|
+
actions.push({ _tag: "Accepted", id, results });
|
|
721
|
+
if (pending || targets.length > 0) {
|
|
722
|
+
// What nothing was built for covers nothing, so it goes at once, and is answered once.
|
|
723
|
+
const later = targets.filter((target) => items.get(target.key)?.phase !== "Accepted");
|
|
724
|
+
for (const target of targets)
|
|
725
|
+
if (!later.includes(target))
|
|
726
|
+
withdraw(target.key, target.reason, target.index >= 0 ? { id, index: target.index } : undefined);
|
|
727
|
+
state = { ...state, batches: [...state.batches, { id, adds, targets: later }] };
|
|
728
|
+
}
|
|
729
|
+
else
|
|
730
|
+
actions.push({ _tag: "Committed", id });
|
|
731
|
+
};
|
|
732
|
+
/** A group's first or last waiting part by place; a replacement shares its part's place. */
|
|
733
|
+
function firstOrLastPart(key, side) {
|
|
734
|
+
const parts = (groups
|
|
735
|
+
.get(key)
|
|
736
|
+
?.parts.map((part) => items.get(part))
|
|
737
|
+
.filter(live) ?? []).sort((a, b) => (a.group?.index ?? 0) - (b.group?.index ?? 0) || a.generation - b.generation);
|
|
738
|
+
return side === "before" ? parts[0] : parts[parts.length - 1];
|
|
739
|
+
}
|
|
740
|
+
/** Records the clip a session plays, and when a new one's start was seen. */
|
|
741
|
+
const nowPlaying = (sessionId, clip) => {
|
|
742
|
+
const playing = session(sessionId)?.playing;
|
|
743
|
+
// A later report of the same clip may name what an earlier one could not.
|
|
744
|
+
if (playing?.clipId === clip.clipId) {
|
|
745
|
+
updateSession(sessionId, {
|
|
746
|
+
playing: {
|
|
747
|
+
...playing,
|
|
748
|
+
tag: clip.tag ?? playing.tag,
|
|
749
|
+
seconds: clip.seconds ?? playing.seconds,
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// A session's first clip on air ends a run of sessions that failed to set up or to play
|
|
755
|
+
// anything. A later clip on a session that already aired says nothing of those.
|
|
756
|
+
if (session(sessionId)?.startedAny === false)
|
|
757
|
+
state = { ...state, setupFailures: 0, countedFailures: 0 };
|
|
758
|
+
updateSession(sessionId, {
|
|
759
|
+
startedAny: true,
|
|
760
|
+
playing: { ...clip, at: now.mono, wall: now.wall },
|
|
761
|
+
});
|
|
762
|
+
if (clip.tag?._tag === "Filler")
|
|
763
|
+
emit({
|
|
764
|
+
_tag: "Filler",
|
|
765
|
+
index: clip.tag.index,
|
|
766
|
+
phase: "Started",
|
|
767
|
+
at: now.wall,
|
|
768
|
+
seconds: clip.seconds,
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
const started = (sessionId, clip) => {
|
|
772
|
+
nowPlaying(sessionId, clip);
|
|
773
|
+
if (clip.tag?._tag !== "Item")
|
|
774
|
+
return;
|
|
775
|
+
const item = items.get(clip.tag.key);
|
|
776
|
+
if (item === undefined || item.startedAt !== undefined || item.phase === "Settled")
|
|
777
|
+
return;
|
|
778
|
+
const lateBy = item.startBy === undefined ? undefined : now.mono - item.startBy;
|
|
779
|
+
const at = atMono(item);
|
|
780
|
+
const late = lateBy !== undefined && lateBy > 0
|
|
781
|
+
? lateBy
|
|
782
|
+
: at !== undefined && now.mono > at
|
|
783
|
+
? now.mono - at
|
|
784
|
+
: undefined;
|
|
785
|
+
set(clip.tag.key, {
|
|
786
|
+
phase: "Started",
|
|
787
|
+
startedAt: now.mono,
|
|
788
|
+
clipId: clip.clipId,
|
|
789
|
+
sessionId,
|
|
790
|
+
waiting: [],
|
|
791
|
+
withdraw: undefined,
|
|
792
|
+
airSeconds: clip.seconds,
|
|
793
|
+
});
|
|
794
|
+
asRun(clip.tag.key, {
|
|
795
|
+
_tag: "Started",
|
|
796
|
+
at: now.wall,
|
|
797
|
+
sessionId,
|
|
798
|
+
// A clip the provider named without its length is counted at the length requested.
|
|
799
|
+
seconds: clip.seconds ?? item.spec.seconds,
|
|
800
|
+
...(late === undefined ? {} : { lateByMillis: Math.round(late) }),
|
|
801
|
+
});
|
|
802
|
+
// A withdrawal that waited on it is too late: it answers now, not when the clip ends.
|
|
803
|
+
for (const wait of item.waiting)
|
|
804
|
+
answer(wait, "already-started");
|
|
805
|
+
};
|
|
806
|
+
const forgetFiller = (sessionId, clipId) => updateSession(sessionId, {
|
|
807
|
+
fillers: new Map([...(session(sessionId)?.fillers ?? [])].filter(([id]) => id !== clipId)),
|
|
808
|
+
});
|
|
809
|
+
const ended = (sessionId, event) => {
|
|
810
|
+
const { clip } = event;
|
|
811
|
+
updateSession(sessionId, {
|
|
812
|
+
lastEndedAt: now.mono,
|
|
813
|
+
...(session(sessionId)?.playing?.clipId === clip.clipId ? { playing: undefined } : {}),
|
|
814
|
+
});
|
|
815
|
+
if (clip.tag?._tag === "Filler") {
|
|
816
|
+
emit({
|
|
817
|
+
_tag: "Filler",
|
|
818
|
+
index: clip.tag.index,
|
|
819
|
+
phase: "Ended",
|
|
820
|
+
at: now.wall,
|
|
821
|
+
seconds: clip.seconds,
|
|
822
|
+
});
|
|
823
|
+
return forgetFiller(sessionId, clip.clipId);
|
|
824
|
+
}
|
|
825
|
+
const item = itemOf(clip);
|
|
826
|
+
if (item === undefined || item.phase === "Settled")
|
|
827
|
+
return;
|
|
828
|
+
if (item.startedAt === undefined) {
|
|
829
|
+
settle(item.spec.key, { _tag: "Unobserved" });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const aired = event.airedSeconds ?? (now.mono - item.startedAt) / 1000;
|
|
833
|
+
settle(item.spec.key, {
|
|
834
|
+
_tag: "Ended",
|
|
835
|
+
at: now.wall,
|
|
836
|
+
termination: event.termination,
|
|
837
|
+
airedSeconds: aired,
|
|
838
|
+
});
|
|
839
|
+
state = {
|
|
840
|
+
...state,
|
|
841
|
+
samples: {
|
|
842
|
+
...state.samples,
|
|
843
|
+
length: [...state.samples.length, clip.seconds / item.spec.seconds].slice(-maxSamples),
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
};
|
|
847
|
+
/**
|
|
848
|
+
* A moderation verdict. On `terminate` the latest enqueue sent to the session
|
|
849
|
+
* is held to blame: it fails for good rather than be rebuilt on the next
|
|
850
|
+
* session and flagged again, and enough such endings fail the playout.
|
|
851
|
+
*/
|
|
852
|
+
const moderated = (sessionId, event) => {
|
|
853
|
+
const terminate = event.action === "terminate";
|
|
854
|
+
const suspect = terminate ? session(sessionId)?.lastEnqueue : undefined;
|
|
855
|
+
const key = suspect?._tag === "Item" ? suspect.key : undefined;
|
|
856
|
+
emit({
|
|
857
|
+
_tag: "Session",
|
|
858
|
+
event: {
|
|
859
|
+
_tag: "Moderated",
|
|
860
|
+
sessionId,
|
|
861
|
+
action: event.action,
|
|
862
|
+
categories: event.categories,
|
|
863
|
+
...(key === undefined ? {} : { key }),
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
if (!terminate)
|
|
867
|
+
return;
|
|
868
|
+
if (key !== undefined)
|
|
869
|
+
settle(key, {
|
|
870
|
+
_tag: "Failed",
|
|
871
|
+
reason: { _tag: "Moderated", categories: event.categories },
|
|
872
|
+
});
|
|
873
|
+
// A flagged filler request is not asked for again.
|
|
874
|
+
if (suspect?._tag === "Filler")
|
|
875
|
+
state = {
|
|
876
|
+
...state,
|
|
877
|
+
filler: {
|
|
878
|
+
...state.filler,
|
|
879
|
+
retries: state.filler.retries.filter((retry) => retry.index !== suspect.index),
|
|
880
|
+
flagged: [...state.filler.flagged, suspect.index],
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
const moderations = state.moderations + 1;
|
|
884
|
+
state = { ...state, moderations };
|
|
885
|
+
if (moderations >= config.maxModerations)
|
|
886
|
+
actions.push({
|
|
887
|
+
_tag: "Fail",
|
|
888
|
+
reason: `content moderation ended ${String(moderations)} sessions`,
|
|
889
|
+
cause: "moderation",
|
|
890
|
+
});
|
|
891
|
+
};
|
|
892
|
+
const failed = (sessionId, event) => {
|
|
893
|
+
const { clip } = event;
|
|
894
|
+
// A clip that fails on air leaves it as an ended one does, and the switch's grace counts from now.
|
|
895
|
+
const onAir = session(sessionId)?.playing?.clipId === clip.clipId;
|
|
896
|
+
if (onAir)
|
|
897
|
+
updateSession(sessionId, { lastEndedAt: now.mono, playing: undefined });
|
|
898
|
+
if (clip.tag?._tag === "Filler") {
|
|
899
|
+
if (onAir)
|
|
900
|
+
emit({
|
|
901
|
+
_tag: "Filler",
|
|
902
|
+
index: clip.tag.index,
|
|
903
|
+
phase: "Ended",
|
|
904
|
+
at: now.wall,
|
|
905
|
+
seconds: clip.seconds,
|
|
906
|
+
});
|
|
907
|
+
return forgetFiller(sessionId, clip.clipId);
|
|
908
|
+
}
|
|
909
|
+
const item = itemOf(clip);
|
|
910
|
+
if (item !== undefined)
|
|
911
|
+
settle(item.spec.key, {
|
|
912
|
+
_tag: "Failed",
|
|
913
|
+
reason: { _tag: "Clip", message: event.message, provider: event.provider },
|
|
914
|
+
});
|
|
915
|
+
};
|
|
916
|
+
/** Reads a session's queues back into the plan: adoption by key, Ready, and clips that vanished. */
|
|
917
|
+
const observe = (sessionId, source) => {
|
|
918
|
+
report(sessionId, source);
|
|
919
|
+
// What plays is what the provider reports while its report is current; a clip seen playing
|
|
920
|
+
// without its start event started no later than now. H3 answers a start or an end with the
|
|
921
|
+
// facts it held before it, and says so: they would name the clip that played before.
|
|
922
|
+
if (source.available) {
|
|
923
|
+
if (source.playing === undefined)
|
|
924
|
+
updateSession(sessionId, { playing: undefined });
|
|
925
|
+
else
|
|
926
|
+
nowPlaying(sessionId, source.playing);
|
|
927
|
+
}
|
|
928
|
+
const listed = new Map();
|
|
929
|
+
for (const clip of source.building)
|
|
930
|
+
listed.set(clip.clipId, "Building");
|
|
931
|
+
for (const clip of source.ready)
|
|
932
|
+
listed.set(clip.clipId, "Ready");
|
|
933
|
+
if (source.playing !== undefined)
|
|
934
|
+
listed.set(source.playing.clipId, "Playing");
|
|
935
|
+
const fillers = new Map(session(sessionId)?.fillers);
|
|
936
|
+
const sample = (kind, dispatchedAt, seconds) => {
|
|
937
|
+
const value = (now.mono - dispatchedAt) / 1000 / seconds;
|
|
938
|
+
state = {
|
|
939
|
+
...state,
|
|
940
|
+
samples: { ...state.samples, [kind]: [...state.samples[kind], value].slice(-maxSamples) },
|
|
941
|
+
};
|
|
942
|
+
};
|
|
943
|
+
for (const clip of [
|
|
944
|
+
...source.building,
|
|
945
|
+
...source.ready,
|
|
946
|
+
...(source.playing === undefined ? [] : [source.playing]),
|
|
947
|
+
]) {
|
|
948
|
+
if (clip.tag?._tag === "Filler") {
|
|
949
|
+
// A filler build is measured as an item's is, so a channel airing only filler learns its
|
|
950
|
+
// build rate, and the floor covers a build.
|
|
951
|
+
const build = fillers.get(clip.clipId)?.build;
|
|
952
|
+
const where = listed.get(clip.clipId);
|
|
953
|
+
if (build !== undefined && where === "Ready")
|
|
954
|
+
sample("build", build.dispatchedAt, build.seconds);
|
|
955
|
+
fillers.set(clip.clipId, {
|
|
956
|
+
index: clip.tag.index,
|
|
957
|
+
build: where === "Building" ? build : undefined,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
if (clip.tag?._tag !== "Item")
|
|
961
|
+
continue;
|
|
962
|
+
const item = items.get(clip.tag.key);
|
|
963
|
+
if (item === undefined || item.phase === "Settled")
|
|
964
|
+
continue;
|
|
965
|
+
const where = listed.get(clip.clipId);
|
|
966
|
+
if (where !== "Playing" &&
|
|
967
|
+
item.discarded?.sessionId === sessionId &&
|
|
968
|
+
item.discarded.clipId === clip.clipId)
|
|
969
|
+
continue;
|
|
970
|
+
if (where === "Playing") {
|
|
971
|
+
if (item.startedAt === undefined) {
|
|
972
|
+
if (item.clipId === undefined)
|
|
973
|
+
set(item.spec.key, { clipId: clip.clipId, sessionId });
|
|
974
|
+
started(sessionId, clip);
|
|
975
|
+
}
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
// Evidence only moves an item forward: a queue snapshot older than its start cannot undo it.
|
|
979
|
+
const waiting = item.phase === "Accepted" || item.phase === "Building" || item.phase === "Unknown";
|
|
980
|
+
if (where === "Ready" && waiting) {
|
|
981
|
+
// A continued build takes longer, so it is measured apart from independent ones.
|
|
982
|
+
if (item.dispatchedAt !== undefined && !item.everUnknown)
|
|
983
|
+
sample(item.continued === true ? "continued" : "build", item.dispatchedAt, item.spec.seconds);
|
|
984
|
+
set(item.spec.key, {
|
|
985
|
+
phase: "Ready",
|
|
986
|
+
clipId: clip.clipId,
|
|
987
|
+
sessionId,
|
|
988
|
+
dispatchedAt: undefined,
|
|
989
|
+
unknownSince: undefined,
|
|
990
|
+
unbuiltLosses: 0,
|
|
991
|
+
});
|
|
992
|
+
asRun(item.spec.key, { _tag: "Ready", sessionId });
|
|
993
|
+
}
|
|
994
|
+
else if (where === "Building" &&
|
|
995
|
+
waiting &&
|
|
996
|
+
(item.phase !== "Building" || item.clipId === undefined)) {
|
|
997
|
+
set(item.spec.key, {
|
|
998
|
+
phase: "Building",
|
|
999
|
+
clipId: clip.clipId,
|
|
1000
|
+
sessionId,
|
|
1001
|
+
unknownSince: undefined,
|
|
1002
|
+
});
|
|
1003
|
+
asRun(item.spec.key, { _tag: "Building", sessionId });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// A filler clip listed here proves where its uncertain enqueue went.
|
|
1007
|
+
const listedFiller = new Set([...fillers.values()].map((owner) => owner.index));
|
|
1008
|
+
const uncertain = session(sessionId)?.unknownFiller ?? [];
|
|
1009
|
+
if (uncertain.some((entry) => listedFiller.has(entry.index)))
|
|
1010
|
+
updateSession(sessionId, {
|
|
1011
|
+
unknownFiller: uncertain.filter((entry) => !listedFiller.has(entry.index)),
|
|
1012
|
+
});
|
|
1013
|
+
// A filler the provider no longer lists has aired or failed; either way it stops being ours.
|
|
1014
|
+
for (const clipId of fillers.keys())
|
|
1015
|
+
if (!listed.has(clipId))
|
|
1016
|
+
fillers.delete(clipId);
|
|
1017
|
+
updateSession(sessionId, { fillers });
|
|
1018
|
+
// A clip that left every queue without a start we saw may have aired unseen.
|
|
1019
|
+
for (const item of items.values())
|
|
1020
|
+
if (item.sessionId === sessionId &&
|
|
1021
|
+
item.clipId !== undefined &&
|
|
1022
|
+
(item.phase === "Building" || item.phase === "Ready") &&
|
|
1023
|
+
!listed.has(item.clipId))
|
|
1024
|
+
settle(item.spec.key, item.withdraw !== undefined
|
|
1025
|
+
? { _tag: "Dropped", reason: item.withdraw }
|
|
1026
|
+
: { _tag: "Unobserved" });
|
|
1027
|
+
};
|
|
1028
|
+
/**
|
|
1029
|
+
* After a failed setup that counts, once `maxSetupFailures` do: with no
|
|
1030
|
+
* session holding the air, the playout fails. While `air` holds it, opening
|
|
1031
|
+
* pauses until it no longer does, when one more open is tried once the last
|
|
1032
|
+
* failure's wait is over.
|
|
1033
|
+
*/
|
|
1034
|
+
const pauseOrFail = (reason, cause, air) => {
|
|
1035
|
+
if (state.countedFailures < config.maxSetupFailures)
|
|
1036
|
+
return;
|
|
1037
|
+
if (holding(air))
|
|
1038
|
+
state = { ...state, openingPaused: true };
|
|
1039
|
+
else
|
|
1040
|
+
actions.push({ _tag: "Fail", reason, cause });
|
|
1041
|
+
};
|
|
1042
|
+
/**
|
|
1043
|
+
* A session that is gone: its unaired clips are rebuilt from the plan, never
|
|
1044
|
+
* replayed. Reactor ends a session over flagged content, and need not say
|
|
1045
|
+
* so, so rebuilding is bounded twice. A clip lost before it was built with
|
|
1046
|
+
* two sessions in a row fails: a built clip passed screening, so it is
|
|
1047
|
+
* rebuilt however often it is lost, and never fails alongside a flagged one.
|
|
1048
|
+
* And a session lost before any clip sent to it started counts as a failed setup.
|
|
1049
|
+
* Whatever ended it here, it is closed: an owned session still running server-side
|
|
1050
|
+
* would otherwise bill beside its replacement.
|
|
1051
|
+
*/
|
|
1052
|
+
const lose = (sessionId, reason, planned = false) => {
|
|
1053
|
+
const lost = session(sessionId);
|
|
1054
|
+
if (lost === undefined)
|
|
1055
|
+
return;
|
|
1056
|
+
actions.push({ _tag: "Close", sessionId });
|
|
1057
|
+
let carried = 0;
|
|
1058
|
+
for (const item of items.values()) {
|
|
1059
|
+
if (item.sessionId !== sessionId || item.phase === "Settled")
|
|
1060
|
+
continue;
|
|
1061
|
+
if (item.phase === "Started")
|
|
1062
|
+
settle(item.spec.key, { _tag: "Failed", reason: { _tag: "Lost", sessionId } });
|
|
1063
|
+
else if (item.phase === "Unknown")
|
|
1064
|
+
settle(item.spec.key, { _tag: "Unknown", terminal: true });
|
|
1065
|
+
else if (item.withdraw !== undefined)
|
|
1066
|
+
settle(item.spec.key, { _tag: "Dropped", reason: item.withdraw });
|
|
1067
|
+
else if (!planned && item.phase === "Building" && item.unbuiltLosses + 1 >= 2)
|
|
1068
|
+
settle(item.spec.key, { _tag: "Failed", reason: { _tag: "Lost", sessionId } });
|
|
1069
|
+
else {
|
|
1070
|
+
carried++;
|
|
1071
|
+
set(item.spec.key, {
|
|
1072
|
+
phase: "Accepted",
|
|
1073
|
+
clipId: undefined,
|
|
1074
|
+
sessionId: undefined,
|
|
1075
|
+
dispatchedAt: undefined,
|
|
1076
|
+
covered: undefined,
|
|
1077
|
+
...(!planned && item.phase === "Building"
|
|
1078
|
+
? { unbuiltLosses: item.unbuiltLosses + 1 }
|
|
1079
|
+
: {}),
|
|
1080
|
+
});
|
|
1081
|
+
asRun(item.spec.key, { _tag: "Accepted", carried: { sessionId } });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
// A filler enqueue in flight there made no clip that can still air: it is asked for again.
|
|
1085
|
+
const lostCommand = lost.busy?.command;
|
|
1086
|
+
if (lostCommand?._tag === "Enqueue" && lostCommand.tag._tag === "Filler")
|
|
1087
|
+
retryFiller(lostCommand.tag.index, lostCommand.request);
|
|
1088
|
+
if (!planned && !lost.startedAny && lost.lastEnqueue !== undefined) {
|
|
1089
|
+
const consecutive = state.setupFailures + 1;
|
|
1090
|
+
state = {
|
|
1091
|
+
...state,
|
|
1092
|
+
setupFailures: consecutive,
|
|
1093
|
+
countedFailures: state.countedFailures + 1,
|
|
1094
|
+
// As after a failed open, the next waits a second longer, and still for a Retry-After.
|
|
1095
|
+
openRetryAt: Math.max(state.openRetryAt, now.mono + setupDelayMs(config, consecutive)),
|
|
1096
|
+
};
|
|
1097
|
+
emit({
|
|
1098
|
+
_tag: "Session",
|
|
1099
|
+
event: {
|
|
1100
|
+
_tag: "SetupFailed",
|
|
1101
|
+
reason: `lost before a clip sent to it started: ${reason}`,
|
|
1102
|
+
consecutive,
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
pauseOrFail(reason, "lost", state.air === sessionId ? undefined : session(state.air));
|
|
1106
|
+
}
|
|
1107
|
+
state = {
|
|
1108
|
+
...state,
|
|
1109
|
+
sessions: state.sessions.filter((value) => value.id !== sessionId),
|
|
1110
|
+
};
|
|
1111
|
+
if (lost.retiring && carried === 0 && state.air !== sessionId)
|
|
1112
|
+
return;
|
|
1113
|
+
emit({ _tag: "Session", event: { _tag: "Replaced", from: sessionId, reason, carried } });
|
|
1114
|
+
if (state.air === sessionId) {
|
|
1115
|
+
const next = preferred() ?? state.sessions[state.sessions.length - 1];
|
|
1116
|
+
state = { ...state, air: next?.id };
|
|
1117
|
+
if (next !== undefined) {
|
|
1118
|
+
updateSession(next.id, { wantAutoplay: true, retiring: false });
|
|
1119
|
+
actions.push({ _tag: "OnAir", sessionId: next.id });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
const cutFailed = (sessionId) => {
|
|
1124
|
+
if (state.cutting?.sessionId === sessionId)
|
|
1125
|
+
state = { ...state, cutting: { ...state.cutting, stage: "failed" } };
|
|
1126
|
+
};
|
|
1127
|
+
const applyResult = (id, result) => {
|
|
1128
|
+
// The lane it came back on. A result from a session already lost comes too late: the loss
|
|
1129
|
+
// settled or carried what its command was for.
|
|
1130
|
+
const lane = state.sessions.find((value) => value.busy?.id === id);
|
|
1131
|
+
const command = lane?.busy?.command;
|
|
1132
|
+
if (lane === undefined || command === undefined)
|
|
1133
|
+
return;
|
|
1134
|
+
const sessionId = lane.id;
|
|
1135
|
+
updateSession(sessionId, { busy: undefined });
|
|
1136
|
+
switch (command._tag) {
|
|
1137
|
+
case "Enqueue": {
|
|
1138
|
+
if (command.tag._tag === "Filler") {
|
|
1139
|
+
const index = command.tag.index;
|
|
1140
|
+
if (result._tag === "Done") {
|
|
1141
|
+
const clipId = result.clipId;
|
|
1142
|
+
// H3 may list the clip before it replies: one already listed built is not measured.
|
|
1143
|
+
const reported = session(sessionId)?.source;
|
|
1144
|
+
const built = reported !== undefined &&
|
|
1145
|
+
(reported.playing?.clipId === clipId ||
|
|
1146
|
+
reported.ready.some((clip) => clip.clipId === clipId));
|
|
1147
|
+
const sent = lane.fillerSent;
|
|
1148
|
+
const build = sent === undefined || built
|
|
1149
|
+
? undefined
|
|
1150
|
+
: { dispatchedAt: sent.at, seconds: sent.seconds };
|
|
1151
|
+
if (clipId !== undefined)
|
|
1152
|
+
updateSession(sessionId, {
|
|
1153
|
+
fillers: new Map([...lane.fillers, [clipId, { index, build }]]),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
else if (uncertain(result))
|
|
1157
|
+
// Its clip may be building on that session: it holds that session's build slot until
|
|
1158
|
+
// a queue read shows it, the deadline passes or the session goes. It is never asked
|
|
1159
|
+
// for again, since it may be there.
|
|
1160
|
+
updateSession(sessionId, {
|
|
1161
|
+
unknownFiller: [
|
|
1162
|
+
...(session(sessionId)?.unknownFiller ?? []),
|
|
1163
|
+
{ index, since: now.mono },
|
|
1164
|
+
],
|
|
1165
|
+
});
|
|
1166
|
+
else {
|
|
1167
|
+
retryFiller(index, command.request);
|
|
1168
|
+
state = { ...state, filler: { ...state.filler, retryAt: now.mono + retryDelayMs } };
|
|
1169
|
+
updateSession(sessionId, { fillerSent: undefined });
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const item = items.get(command.tag.key);
|
|
1174
|
+
if (item === undefined || item.phase === "Settled")
|
|
1175
|
+
return;
|
|
1176
|
+
const owner = session(sessionId);
|
|
1177
|
+
if (result._tag === "Done") {
|
|
1178
|
+
set(item.spec.key, { unsent: undefined });
|
|
1179
|
+
if (item.clipId === undefined)
|
|
1180
|
+
set(item.spec.key, { clipId: result.clipId, sessionId });
|
|
1181
|
+
if (item.phase === "Unknown")
|
|
1182
|
+
set(item.spec.key, { phase: "Building", unknownSince: undefined });
|
|
1183
|
+
const now_ = items.get(item.spec.key);
|
|
1184
|
+
if (now_.phase === "Building" && now_.status?._tag !== "Building")
|
|
1185
|
+
asRun(item.spec.key, { _tag: "Building", sessionId });
|
|
1186
|
+
}
|
|
1187
|
+
else if (uncertain(result)) {
|
|
1188
|
+
if (item.clipId === undefined) {
|
|
1189
|
+
set(item.spec.key, {
|
|
1190
|
+
phase: "Unknown",
|
|
1191
|
+
sessionId,
|
|
1192
|
+
everUnknown: true,
|
|
1193
|
+
unknownSince: now.mono,
|
|
1194
|
+
});
|
|
1195
|
+
asRun(item.spec.key, { _tag: "Unknown" });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
else if (item.withdraw !== undefined)
|
|
1199
|
+
// Refused with no clip made: the withdrawal waiting on it has nothing left to remove.
|
|
1200
|
+
settle(item.spec.key, { _tag: "Dropped", reason: item.withdraw });
|
|
1201
|
+
else if (result._tag === "Failed" && result.cause.isRetryable)
|
|
1202
|
+
set(item.spec.key, {
|
|
1203
|
+
phase: "Accepted",
|
|
1204
|
+
sessionId: undefined,
|
|
1205
|
+
dispatchedAt: undefined,
|
|
1206
|
+
retryAt: now.mono + retryDelayMs,
|
|
1207
|
+
});
|
|
1208
|
+
else if (result._tag === "Failed" &&
|
|
1209
|
+
unready(result.cause) &&
|
|
1210
|
+
// Refused again while the session says it takes commands, nothing is left to wait for.
|
|
1211
|
+
!(item.unsent?.sessionId === sessionId && owner?.source?.available === true))
|
|
1212
|
+
set(item.spec.key, {
|
|
1213
|
+
phase: "Accepted",
|
|
1214
|
+
sessionId: undefined,
|
|
1215
|
+
dispatchedAt: undefined,
|
|
1216
|
+
unsent: { sessionId, changes: owner?.changes ?? 0 },
|
|
1217
|
+
});
|
|
1218
|
+
else if (result._tag === "Failed")
|
|
1219
|
+
settle(item.spec.key, {
|
|
1220
|
+
_tag: "Failed",
|
|
1221
|
+
reason: { _tag: "Command", cause: result.cause },
|
|
1222
|
+
});
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
case "Remove": {
|
|
1226
|
+
const owner = [...items.values()].find((item) => item.sessionId === sessionId &&
|
|
1227
|
+
item.clipId === command.clipId &&
|
|
1228
|
+
item.phase !== "Settled");
|
|
1229
|
+
// A removal whose outcome is unknown goes again at once: if it applied, the next is
|
|
1230
|
+
// refused, and the clip is gone. One refused goes again once the queues change.
|
|
1231
|
+
const unknown = result._tag === "Failed" && result.cause.context.outcome === "unknown";
|
|
1232
|
+
if (owner === undefined) {
|
|
1233
|
+
if (result._tag === "Done")
|
|
1234
|
+
return forgetFiller(sessionId, command.clipId);
|
|
1235
|
+
if (unknown)
|
|
1236
|
+
return;
|
|
1237
|
+
const refused = session(sessionId);
|
|
1238
|
+
if (refused === undefined)
|
|
1239
|
+
return;
|
|
1240
|
+
const now_ = signature(refused);
|
|
1241
|
+
const earlier = refused.refusedFiller.signature === now_ ? refused.refusedFiller.clipIds : [];
|
|
1242
|
+
updateSession(sessionId, {
|
|
1243
|
+
refusedFiller: { signature: now_, clipIds: [...earlier, command.clipId] },
|
|
1244
|
+
});
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (result._tag === "Done")
|
|
1248
|
+
settle(owner.spec.key, { _tag: "Dropped", reason: owner.withdraw ?? "withdrawn" });
|
|
1249
|
+
else if (!unknown)
|
|
1250
|
+
set(owner.spec.key, { blockedRemove: signature(session(sessionId)) });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
case "Move":
|
|
1254
|
+
if (result._tag === "Failed")
|
|
1255
|
+
updateSession(sessionId, { blockedMove: signature(session(sessionId)) + command.clipId });
|
|
1256
|
+
// One that died may have applied: a read of the queues will show if it did.
|
|
1257
|
+
else if (result._tag === "Died")
|
|
1258
|
+
updateSession(sessionId, { moveRetryAt: now.mono + retryDelayMs });
|
|
1259
|
+
return;
|
|
1260
|
+
case "Autoplay":
|
|
1261
|
+
if (result._tag === "Done")
|
|
1262
|
+
return updateSession(sessionId, { autoplay: command.enabled });
|
|
1263
|
+
updateSession(sessionId, {
|
|
1264
|
+
// One that may have applied leaves autoplay unknown, so the value wanted goes again.
|
|
1265
|
+
...(uncertain(result) ? { autoplay: undefined } : {}),
|
|
1266
|
+
autoplayRetry: { enabled: command.enabled, at: now.mono + retryDelayMs },
|
|
1267
|
+
});
|
|
1268
|
+
if (!command.enabled)
|
|
1269
|
+
cutFailed(sessionId);
|
|
1270
|
+
return;
|
|
1271
|
+
// Its clip was marked cut when the cut began; no result makes it cuttable again.
|
|
1272
|
+
// One whose fiber died may not have been sent, so it fails the cut like a refusal.
|
|
1273
|
+
case "Stop":
|
|
1274
|
+
if (result._tag !== "Done")
|
|
1275
|
+
return cutFailed(sessionId);
|
|
1276
|
+
if (state.cutting?.sessionId === sessionId && state.cutting.clipId === command.clipId)
|
|
1277
|
+
state = { ...state, cutting: { ...state.cutting, stage: "stopped" } };
|
|
1278
|
+
return;
|
|
1279
|
+
case "Play":
|
|
1280
|
+
if (result._tag !== "Done")
|
|
1281
|
+
return cutFailed(sessionId);
|
|
1282
|
+
if (state.cutting?.sessionId === sessionId && state.cutting.next === command.clipId)
|
|
1283
|
+
state = { ...state, cutting: { ...state.cutting, stage: "played" } };
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
switch (input._tag) {
|
|
1288
|
+
case "Edit":
|
|
1289
|
+
applyEdit(input.id, input.edits, input.batch);
|
|
1290
|
+
break;
|
|
1291
|
+
case "Release": {
|
|
1292
|
+
const item = items.get(input.key);
|
|
1293
|
+
if (!live(item) || item.mode !== "held")
|
|
1294
|
+
actions.push({
|
|
1295
|
+
_tag: "Refused",
|
|
1296
|
+
id: input.id,
|
|
1297
|
+
refusal: { _tag: "InvalidItem", key: input.key, message: "no held item has this key" },
|
|
1298
|
+
});
|
|
1299
|
+
else {
|
|
1300
|
+
set(input.key, { mode: "asap" });
|
|
1301
|
+
actions.push({ _tag: "Accepted", id: input.id, results: [] });
|
|
1302
|
+
}
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
case "Drain":
|
|
1306
|
+
state = {
|
|
1307
|
+
...state,
|
|
1308
|
+
accepting: false,
|
|
1309
|
+
drains: [...state.drains, { id: input.id, finish: input.finish }],
|
|
1310
|
+
};
|
|
1311
|
+
for (const item of items.values())
|
|
1312
|
+
if (live(item) &&
|
|
1313
|
+
(item.mode === "held" || (input.finish === "playing" && item.phase !== "Started")))
|
|
1314
|
+
withdraw(item.spec.key, "withdrawn");
|
|
1315
|
+
break;
|
|
1316
|
+
case "Opened": {
|
|
1317
|
+
const first = state.air === undefined;
|
|
1318
|
+
state = {
|
|
1319
|
+
...state,
|
|
1320
|
+
opening: false,
|
|
1321
|
+
air: first ? input.sessionId : state.air,
|
|
1322
|
+
sessions: [
|
|
1323
|
+
...state.sessions,
|
|
1324
|
+
{
|
|
1325
|
+
id: input.sessionId,
|
|
1326
|
+
openedAt: now.mono,
|
|
1327
|
+
lifetimeMs: input.lifetimeMs,
|
|
1328
|
+
source: undefined,
|
|
1329
|
+
changes: 0,
|
|
1330
|
+
autoplay: undefined,
|
|
1331
|
+
wantAutoplay: first,
|
|
1332
|
+
autoplayRetry: undefined,
|
|
1333
|
+
retiring: false,
|
|
1334
|
+
lastEndedAt: undefined,
|
|
1335
|
+
startedAny: false,
|
|
1336
|
+
indeterminate: false,
|
|
1337
|
+
unknownFiller: [],
|
|
1338
|
+
playing: undefined,
|
|
1339
|
+
lastEnqueue: undefined,
|
|
1340
|
+
refusedFiller: { signature: "", clipIds: [] },
|
|
1341
|
+
blockedMove: undefined,
|
|
1342
|
+
moveRetryAt: 0,
|
|
1343
|
+
busy: undefined,
|
|
1344
|
+
fillerSent: undefined,
|
|
1345
|
+
fillers: new Map(),
|
|
1346
|
+
},
|
|
1347
|
+
],
|
|
1348
|
+
};
|
|
1349
|
+
// The session on air before stays until the replacement can take over at a boundary.
|
|
1350
|
+
if (!first)
|
|
1351
|
+
for (const value of state.sessions)
|
|
1352
|
+
if (value.id !== input.sessionId)
|
|
1353
|
+
updateSession(value.id, { retiring: true });
|
|
1354
|
+
if (first)
|
|
1355
|
+
actions.push({ _tag: "OnAir", sessionId: input.sessionId });
|
|
1356
|
+
emit({
|
|
1357
|
+
_tag: "Session",
|
|
1358
|
+
event: {
|
|
1359
|
+
_tag: "Opened",
|
|
1360
|
+
sessionId: input.sessionId,
|
|
1361
|
+
...(Number.isFinite(input.lifetimeMs)
|
|
1362
|
+
? { lifetimeSeconds: input.lifetimeMs / 1000 }
|
|
1363
|
+
: {}),
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
case "OpenFailed": {
|
|
1369
|
+
const consecutive = state.setupFailures + 1;
|
|
1370
|
+
// A refusal that allocated nothing billed nothing: refused while a session holds the air, it
|
|
1371
|
+
// counts toward no limit, then or later, though the next open waits longer.
|
|
1372
|
+
const counted = input.allocated || !holding(session(state.air));
|
|
1373
|
+
state = {
|
|
1374
|
+
...state,
|
|
1375
|
+
opening: false,
|
|
1376
|
+
setupFailures: consecutive,
|
|
1377
|
+
countedFailures: state.countedFailures + (counted ? 1 : 0),
|
|
1378
|
+
// A refusal that says when to ask again is not asked sooner.
|
|
1379
|
+
openRetryAt: now.mono + Math.max(setupDelayMs(config, consecutive), input.retryAfterMs ?? 0),
|
|
1380
|
+
};
|
|
1381
|
+
emit({ _tag: "Session", event: { _tag: "SetupFailed", reason: input.reason, consecutive } });
|
|
1382
|
+
if (input.fatal)
|
|
1383
|
+
actions.push({ _tag: "Fail", reason: input.reason, cause: "open" });
|
|
1384
|
+
// One that doesn't count is asked again after its delay, even once those that do ran out.
|
|
1385
|
+
else if (counted)
|
|
1386
|
+
pauseOrFail(input.reason, "open", session(state.air));
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
case "Source": {
|
|
1390
|
+
if (session(input.sessionId) === undefined)
|
|
1391
|
+
break;
|
|
1392
|
+
const event = input.event;
|
|
1393
|
+
switch (event._tag) {
|
|
1394
|
+
case "State":
|
|
1395
|
+
observe(input.sessionId, event.state);
|
|
1396
|
+
break;
|
|
1397
|
+
case "Started":
|
|
1398
|
+
started(input.sessionId, event.clip);
|
|
1399
|
+
break;
|
|
1400
|
+
case "Ended":
|
|
1401
|
+
ended(input.sessionId, event);
|
|
1402
|
+
break;
|
|
1403
|
+
case "Failed":
|
|
1404
|
+
failed(input.sessionId, event);
|
|
1405
|
+
break;
|
|
1406
|
+
case "Moderated":
|
|
1407
|
+
moderated(input.sessionId, event);
|
|
1408
|
+
break;
|
|
1409
|
+
case "Reconnecting": {
|
|
1410
|
+
// It takes no commands until its source reports a state again.
|
|
1411
|
+
const source = session(input.sessionId)?.source;
|
|
1412
|
+
if (source !== undefined)
|
|
1413
|
+
report(input.sessionId, { ...source, available: false });
|
|
1414
|
+
emit({ _tag: "Session", event: { _tag: "Reconnecting", sessionId: input.sessionId } });
|
|
1415
|
+
break;
|
|
1416
|
+
}
|
|
1417
|
+
case "Reconnected":
|
|
1418
|
+
emit({
|
|
1419
|
+
_tag: "Session",
|
|
1420
|
+
event: {
|
|
1421
|
+
_tag: "Reconnected",
|
|
1422
|
+
sessionId: input.sessionId,
|
|
1423
|
+
afterMillis: event.afterMillis,
|
|
1424
|
+
},
|
|
1425
|
+
});
|
|
1426
|
+
break;
|
|
1427
|
+
case "ReaderOverflow":
|
|
1428
|
+
emit({
|
|
1429
|
+
_tag: "ReaderOverflow",
|
|
1430
|
+
sessionId: input.sessionId,
|
|
1431
|
+
track: event.track,
|
|
1432
|
+
at: now.wall,
|
|
1433
|
+
pressure: event.pressure,
|
|
1434
|
+
});
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1437
|
+
break;
|
|
1438
|
+
}
|
|
1439
|
+
case "Lost":
|
|
1440
|
+
lose(input.sessionId, input.reason);
|
|
1441
|
+
break;
|
|
1442
|
+
case "Result":
|
|
1443
|
+
applyResult(input.id, input.result);
|
|
1444
|
+
break;
|
|
1445
|
+
case "Tick":
|
|
1446
|
+
break;
|
|
1447
|
+
case "Close": {
|
|
1448
|
+
state = { ...state, accepting: false, closed: true };
|
|
1449
|
+
// A pending batch's withdrawals take effect now: what they name never airs, and each
|
|
1450
|
+
// is answered with what became of its item (#64).
|
|
1451
|
+
for (const batch of state.batches)
|
|
1452
|
+
for (const target of batch.targets)
|
|
1453
|
+
withdraw(target.key, target.reason, target.index >= 0 ? { id: batch.id, index: target.index } : undefined);
|
|
1454
|
+
for (const item of items.values()) {
|
|
1455
|
+
if (item.phase === "Settled")
|
|
1456
|
+
continue;
|
|
1457
|
+
if (item.phase === "Unknown")
|
|
1458
|
+
settle(item.spec.key, { _tag: "Unknown", terminal: true });
|
|
1459
|
+
else if (item.phase === "Started")
|
|
1460
|
+
settle(item.spec.key, { _tag: "Unobserved" });
|
|
1461
|
+
else if (item.withdraw !== undefined)
|
|
1462
|
+
settle(item.spec.key, { _tag: "Dropped", reason: item.withdraw });
|
|
1463
|
+
else
|
|
1464
|
+
settle(item.spec.key, { _tag: "Failed", reason: { _tag: "Closed" } });
|
|
1465
|
+
}
|
|
1466
|
+
for (const batch of state.batches)
|
|
1467
|
+
actions.push({ _tag: "Refused", id: batch.id, refusal: { _tag: "PlayoutClosed" } });
|
|
1468
|
+
for (const drain of state.drains)
|
|
1469
|
+
actions.push({ _tag: "Drained", id: drain.id });
|
|
1470
|
+
state = { ...state, batches: [], drains: [] };
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (state.closed)
|
|
1475
|
+
return { state: { ...state, items }, actions, wake: undefined };
|
|
1476
|
+
// Sweep every waiting item, not only the heads: expiry must not strand behind a live one. Each
|
|
1477
|
+
// is late from the instant its deadline falls due, where the wake is.
|
|
1478
|
+
const boundaryLate = (item) => {
|
|
1479
|
+
const at = atMono(item);
|
|
1480
|
+
if (at === undefined || item.spec.start._tag !== "At")
|
|
1481
|
+
return false;
|
|
1482
|
+
const late = item.spec.start.late;
|
|
1483
|
+
return late === "nextBoundary"
|
|
1484
|
+
? false
|
|
1485
|
+
: now.mono >= (late === "drop" ? at : at + late.skipAfterMs);
|
|
1486
|
+
};
|
|
1487
|
+
for (const item of [...items.values()]) {
|
|
1488
|
+
if (item.phase !== "Accepted" && item.phase !== "Building" && item.phase !== "Ready")
|
|
1489
|
+
continue;
|
|
1490
|
+
if ((item.spec.window?.firm === true && item.startBy !== undefined && now.mono >= item.startBy) ||
|
|
1491
|
+
boundaryLate(item))
|
|
1492
|
+
withdraw(item.spec.key, "late");
|
|
1493
|
+
else if (lateWhenProjected(item) && misses(item, item.startBy))
|
|
1494
|
+
withdraw(item.spec.key, "late");
|
|
1495
|
+
}
|
|
1496
|
+
// A replacement takes the place once Ready, or at once if nothing was built for what it
|
|
1497
|
+
// replaces; if what it replaces starts first, the replacement goes instead.
|
|
1498
|
+
for (const item of [...items.values()]) {
|
|
1499
|
+
if (item.replaces === undefined || item.phase === "Settled")
|
|
1500
|
+
continue;
|
|
1501
|
+
const old = replacedOf(item);
|
|
1502
|
+
if (old === undefined || old.phase === "Settled")
|
|
1503
|
+
continue;
|
|
1504
|
+
if (old.phase === "Started") {
|
|
1505
|
+
if (item.phase !== "Started")
|
|
1506
|
+
withdraw(item.spec.key, "withdrawn");
|
|
1507
|
+
}
|
|
1508
|
+
else if ((item.phase === "Ready" && item.batch === undefined) ||
|
|
1509
|
+
item.phase === "Started" ||
|
|
1510
|
+
(old.phase === "Accepted" && old.dispatchedAt === undefined))
|
|
1511
|
+
withdraw(old.spec.key, "replaced");
|
|
1512
|
+
}
|
|
1513
|
+
// A batch takes effect once everything it adds is Ready or has settled.
|
|
1514
|
+
for (const batch of state.batches) {
|
|
1515
|
+
const done = batch.adds.every((key) => {
|
|
1516
|
+
const phase = items.get(key)?.phase;
|
|
1517
|
+
return phase === undefined || phase === "Ready" || phase === "Started" || phase === "Settled";
|
|
1518
|
+
});
|
|
1519
|
+
if (!done)
|
|
1520
|
+
continue;
|
|
1521
|
+
state = { ...state, batches: state.batches.filter((value) => value.id !== batch.id) };
|
|
1522
|
+
for (const key of batch.adds)
|
|
1523
|
+
if (items.has(key))
|
|
1524
|
+
set(key, { batch: undefined });
|
|
1525
|
+
for (const target of batch.targets)
|
|
1526
|
+
withdraw(target.key, target.reason, target.index >= 0 ? { id: batch.id, index: target.index } : undefined);
|
|
1527
|
+
actions.push({ _tag: "Committed", id: batch.id });
|
|
1528
|
+
}
|
|
1529
|
+
// An enqueue whose outcome stays unknown past the deadline makes its session indeterminate:
|
|
1530
|
+
// it takes no new work and a replacement takes over, now rather than at the next input. One
|
|
1531
|
+
// not on air has nothing to finish, so it goes at once.
|
|
1532
|
+
const expired = (since) => since !== undefined && now.mono >= since + config.unknownTimeoutMs;
|
|
1533
|
+
for (const value of [...state.sessions]) {
|
|
1534
|
+
if (value.indeterminate)
|
|
1535
|
+
continue;
|
|
1536
|
+
const stuck = value.unknownFiller.some((entry) => expired(entry.since)) ||
|
|
1537
|
+
[...items.values()].some((item) => item.phase === "Unknown" && item.sessionId === value.id && expired(item.unknownSince));
|
|
1538
|
+
if (!stuck)
|
|
1539
|
+
continue;
|
|
1540
|
+
updateSession(value.id, { indeterminate: true });
|
|
1541
|
+
if (value.id !== state.air)
|
|
1542
|
+
lose(value.id, "an enqueue's outcome stayed unknown");
|
|
1543
|
+
}
|
|
1544
|
+
// Renewal.
|
|
1545
|
+
const expiresAt = (value) => value.openedAt + value.lifetimeMs;
|
|
1546
|
+
for (const value of state.sessions)
|
|
1547
|
+
if (now.mono >= expiresAt(value) && value.lifetimeMs !== Infinity)
|
|
1548
|
+
lose(value.id, "the session's granted length ended");
|
|
1549
|
+
// Setups ran out while a session held the air; once none does, one more open is tried, once
|
|
1550
|
+
// the last failure's wait is over.
|
|
1551
|
+
if (state.openingPaused && !holding(session(state.air)))
|
|
1552
|
+
state = { ...state, openingPaused: false };
|
|
1553
|
+
const air = session(state.air);
|
|
1554
|
+
const wantsAir = state.accepting ||
|
|
1555
|
+
state.drains.some((drain) => drain.finish === "accepted") ||
|
|
1556
|
+
[...items.values()].some(live);
|
|
1557
|
+
if (!state.opening && !state.openingPaused && now.mono >= state.openRetryAt && wantsAir) {
|
|
1558
|
+
const replacementLive = state.sessions.some((value) => !value.retiring && value.id !== state.air);
|
|
1559
|
+
// Only the lead opens a replacement. What doesn't fit before the cap waits for it: the
|
|
1560
|
+
// session on air then holds air nearly to its cap, and a replacement opened earlier would
|
|
1561
|
+
// bill while it waits to air.
|
|
1562
|
+
const due = air === undefined ||
|
|
1563
|
+
(!replacementLive && (air.indeterminate || now.mono >= expiresAt(air) - config.leadMs));
|
|
1564
|
+
if (due && state.sessions.length < 2) {
|
|
1565
|
+
state = { ...state, opening: true };
|
|
1566
|
+
actions.push({ _tag: "Open" });
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
const current = session(state.air);
|
|
1570
|
+
const next = state.sessions.find((value) => value.id !== state.air && !value.retiring);
|
|
1571
|
+
if (current !== undefined && next !== undefined && next.source?.available === true) {
|
|
1572
|
+
// The drain rule: once the replacement has an item Ready, the retiring filler goes.
|
|
1573
|
+
if (readyOf(next).some((clip) => clip.tag?._tag === "Item"))
|
|
1574
|
+
for (const clip of readyOf(current))
|
|
1575
|
+
if (fillerRemovable(current, clip))
|
|
1576
|
+
queueCommand(current.id, { _tag: "Remove", clipId: clip.clipId });
|
|
1577
|
+
const idle = current.source !== undefined &&
|
|
1578
|
+
current.source.playing === undefined &&
|
|
1579
|
+
current.source.ready.length === 0 &&
|
|
1580
|
+
current.source.building.every((clip) => clip.tag === undefined) &&
|
|
1581
|
+
![...items.values()].some((item) => item.sessionId === current.id &&
|
|
1582
|
+
(item.phase === "Building" || (item.phase === "Unknown" && !expired(item.unknownSince)))) &&
|
|
1583
|
+
current.unknownFiller.every((entry) => expired(entry.since));
|
|
1584
|
+
const graceOver = current.lastEndedAt !== undefined && now.mono >= current.lastEndedAt + config.graceMs;
|
|
1585
|
+
if (idle && (!current.startedAny || graceOver)) {
|
|
1586
|
+
state = { ...state, air: next.id };
|
|
1587
|
+
updateSession(next.id, { wantAutoplay: true });
|
|
1588
|
+
actions.push({ _tag: "OnAir", sessionId: next.id });
|
|
1589
|
+
emit({
|
|
1590
|
+
_tag: "Session",
|
|
1591
|
+
event: {
|
|
1592
|
+
_tag: "Switched",
|
|
1593
|
+
from: current.id,
|
|
1594
|
+
to: next.id,
|
|
1595
|
+
decision: current.startedAny ? "grace-elapsed" : "no-observed-start",
|
|
1596
|
+
},
|
|
1597
|
+
});
|
|
1598
|
+
lose(current.id, "retired", true);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
// Starvation: nothing on air while the plan wants air.
|
|
1602
|
+
const onAir = session(state.air);
|
|
1603
|
+
const taking = state.sessions.some((value) => value.id !== state.air && !value.retiring && readyOf(value).length > 0);
|
|
1604
|
+
const dry = onAir?.source !== undefined &&
|
|
1605
|
+
onAir.source.playing === undefined &&
|
|
1606
|
+
onAir.source.ready.length === 0 &&
|
|
1607
|
+
onAir.startedAny &&
|
|
1608
|
+
!taking;
|
|
1609
|
+
const owed = [...items.values()].some((item) => item.phase !== "Settled" && item.mode !== "held") ||
|
|
1610
|
+
config.filler !== undefined;
|
|
1611
|
+
if (dry && owed && !state.starving) {
|
|
1612
|
+
state = { ...state, starving: true, starved: state.starved + 1 };
|
|
1613
|
+
emit({ _tag: "Starved", at: now.wall });
|
|
1614
|
+
}
|
|
1615
|
+
else if (!dry && state.starving)
|
|
1616
|
+
state = { ...state, starving: false };
|
|
1617
|
+
const fireCue = (item, index) => emit({
|
|
1618
|
+
_tag: "Cue",
|
|
1619
|
+
event: { key: item.spec.key, name: item.spec.cues[index].name, at: now.wall },
|
|
1620
|
+
});
|
|
1621
|
+
// Cues of the playing item fall due on its observed start.
|
|
1622
|
+
for (const item of [...items.values()]) {
|
|
1623
|
+
if (item.phase !== "Started" || item.startedAt === undefined || item.spec.cues.length === 0)
|
|
1624
|
+
continue;
|
|
1625
|
+
const due = item.spec.cues
|
|
1626
|
+
.map((cue, index) => ({ index, at: cueAt(item, cue) }))
|
|
1627
|
+
.filter(({ index, at }) => !item.fired.includes(index) && at <= now.mono)
|
|
1628
|
+
.sort((a, b) => a.at - b.at);
|
|
1629
|
+
for (const { index } of due)
|
|
1630
|
+
fireCue(item, index);
|
|
1631
|
+
if (due.length > 0)
|
|
1632
|
+
set(item.spec.key, { fired: [...item.fired, ...due.map(({ index }) => index)] });
|
|
1633
|
+
}
|
|
1634
|
+
// A playing clip's cues after its end fire at the end, if it ended there.
|
|
1635
|
+
for (const item of [...items.values()])
|
|
1636
|
+
if (item.phase === "Settled" &&
|
|
1637
|
+
item.status?._tag === "Ended" &&
|
|
1638
|
+
item.startedAt !== undefined &&
|
|
1639
|
+
item.fired.length < item.spec.cues.length) {
|
|
1640
|
+
const fire = item.spec.cues
|
|
1641
|
+
.map((_, index) => index)
|
|
1642
|
+
.filter((index) => !item.fired.includes(index) && cueAt(item, item.spec.cues[index]) <= now.mono + 1);
|
|
1643
|
+
for (const index of fire)
|
|
1644
|
+
fireCue(item, index);
|
|
1645
|
+
set(item.spec.key, { fired: item.spec.cues.map((_, index) => index) });
|
|
1646
|
+
}
|
|
1647
|
+
// Drains finish once nothing they wait for is left.
|
|
1648
|
+
for (const drain of state.drains) {
|
|
1649
|
+
const pending = [...items.values()].some((item) => live(item) && (drain.finish === "accepted" || item.phase === "Started"));
|
|
1650
|
+
if (!pending) {
|
|
1651
|
+
actions.push({ _tag: "Drained", id: drain.id });
|
|
1652
|
+
state = { ...state, drains: state.drains.filter((value) => value.id !== drain.id) };
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
// Each session's commands go one at a time on a lane of its own.
|
|
1656
|
+
endCut();
|
|
1657
|
+
for (const value of state.sessions)
|
|
1658
|
+
decideCommand(value.id);
|
|
1659
|
+
// History: settled keys past the bound are forgotten, oldest first.
|
|
1660
|
+
if (state.settled.length > config.maxHistory) {
|
|
1661
|
+
const drop = state.settled.slice(0, state.settled.length - config.maxHistory);
|
|
1662
|
+
for (const key of drop)
|
|
1663
|
+
items.delete(key);
|
|
1664
|
+
actions.push({ _tag: "Forget", keys: drop });
|
|
1665
|
+
for (const [group, value] of groups)
|
|
1666
|
+
if (value.parts.every((part) => !items.has(part)))
|
|
1667
|
+
groups.delete(group);
|
|
1668
|
+
state = { ...state, settled: state.settled.slice(drop.length) };
|
|
1669
|
+
}
|
|
1670
|
+
return { state: { ...state, items, groups }, actions, wake: wake() };
|
|
1671
|
+
function queueCommand(sessionId, command) {
|
|
1672
|
+
const lane = session(sessionId);
|
|
1673
|
+
if (lane === undefined || lane.busy !== undefined)
|
|
1674
|
+
return;
|
|
1675
|
+
const id = state.nextCommand;
|
|
1676
|
+
state = { ...state, nextCommand: id + 1 };
|
|
1677
|
+
updateSession(sessionId, {
|
|
1678
|
+
busy: { id, command },
|
|
1679
|
+
...(command._tag === "Enqueue" ? { lastEnqueue: command.tag } : {}),
|
|
1680
|
+
});
|
|
1681
|
+
actions.push({ _tag: "Command", id, sessionId, command });
|
|
1682
|
+
}
|
|
1683
|
+
/** A filler clip a session takes commands for, unless its removal was refused as things stand. */
|
|
1684
|
+
function fillerRemovable(value, clip) {
|
|
1685
|
+
return (clip.tag?._tag === "Filler" &&
|
|
1686
|
+
value.source?.available === true &&
|
|
1687
|
+
!(value.refusedFiller.signature === signature(value) &&
|
|
1688
|
+
value.refusedFiller.clipIds.includes(clip.clipId)));
|
|
1689
|
+
}
|
|
1690
|
+
/** A session's queues as they stand: a clip finishing its build changes them too. */
|
|
1691
|
+
function signature(value) {
|
|
1692
|
+
const source = value?.source;
|
|
1693
|
+
return source === undefined
|
|
1694
|
+
? ""
|
|
1695
|
+
: [source.building, source.ready, source.playing === undefined ? [] : [source.playing]]
|
|
1696
|
+
.map((clips) => clips.map((clip) => clip.clipId).join(","))
|
|
1697
|
+
.join(":");
|
|
1698
|
+
}
|
|
1699
|
+
function cueAt(item, cue) {
|
|
1700
|
+
return cue.from === "start"
|
|
1701
|
+
? item.startedAt + cue.offsetMs
|
|
1702
|
+
: item.startedAt + (item.airSeconds ?? item.spec.seconds) * 1000 - cue.offsetMs;
|
|
1703
|
+
}
|
|
1704
|
+
/** The cut's cutter while it waits Ready on its session, and whether the plan still wants it. */
|
|
1705
|
+
function cutterOf() {
|
|
1706
|
+
const cutting = state.cutting;
|
|
1707
|
+
const cutter = session(cutting?.sessionId)?.source?.ready.find((clip) => clip.clipId === cutting?.next);
|
|
1708
|
+
return {
|
|
1709
|
+
cutter,
|
|
1710
|
+
wanted: cutter !== undefined && airs(cutter) && itemOf(cutter)?.phase === "Ready",
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* A cut ends once its cutter has left its session's Ready queue; once a step
|
|
1715
|
+
* failed with the cutter still wanted, which then airs at the next boundary;
|
|
1716
|
+
* or once a cutter no longer wanted is not being removed.
|
|
1717
|
+
*/
|
|
1718
|
+
function endCut() {
|
|
1719
|
+
const cutting = state.cutting;
|
|
1720
|
+
if (cutting === undefined)
|
|
1721
|
+
return;
|
|
1722
|
+
const { cutter, wanted } = cutterOf();
|
|
1723
|
+
const cutterItem = itemOf(cutter);
|
|
1724
|
+
const removing = cutterItem?.withdraw !== undefined &&
|
|
1725
|
+
cutterItem.blockedRemove !== signature(session(cutting.sessionId));
|
|
1726
|
+
if (cutter === undefined || (cutting.stage === "failed" && wanted) || (!wanted && !removing))
|
|
1727
|
+
state = { ...state, cutting: undefined };
|
|
1728
|
+
}
|
|
1729
|
+
/** The next command for a session whose lane is free, if the plan wants one there. */
|
|
1730
|
+
function decideCommand(sessionId) {
|
|
1731
|
+
const value = session(sessionId);
|
|
1732
|
+
if (value === undefined || value.busy !== undefined)
|
|
1733
|
+
return;
|
|
1734
|
+
// Autoplay as its role wants it: off on a replacement until it takes the air, and off on the
|
|
1735
|
+
// air while a cut is under way.
|
|
1736
|
+
const autoplay = value.wantAutoplay && state.cutting?.sessionId !== value.id;
|
|
1737
|
+
if (value.source?.available === true && value.autoplay !== autoplay) {
|
|
1738
|
+
// Nothing else goes before it, even while a failed one waits to be asked again.
|
|
1739
|
+
const retry = value.autoplayRetry;
|
|
1740
|
+
if (retry === undefined || retry.enabled !== autoplay || now.mono >= retry.at)
|
|
1741
|
+
queueCommand(value.id, { _tag: "Autoplay", enabled: autoplay });
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
// The cut's next step, with autoplay off: stop the clip it cuts, and once that has ended,
|
|
1745
|
+
// play the cutter. The plan looks again between them, so a cutter withdrawn meanwhile goes
|
|
1746
|
+
// instead, and until its play nothing else is sent that could hold it up.
|
|
1747
|
+
const cutting = state.cutting;
|
|
1748
|
+
if (cutting?.sessionId === value.id &&
|
|
1749
|
+
(cutting.stage === "stopping" || cutting.stage === "stopped") &&
|
|
1750
|
+
cutterOf().wanted &&
|
|
1751
|
+
value.source?.available === true) {
|
|
1752
|
+
const playing = value.source.playing?.clipId;
|
|
1753
|
+
if (cutting.stage === "stopping")
|
|
1754
|
+
return queueCommand(value.id, { _tag: "Stop", clipId: cutting.clipId });
|
|
1755
|
+
if (playing === undefined)
|
|
1756
|
+
return queueCommand(value.id, { _tag: "Play", clipId: cutting.next });
|
|
1757
|
+
if (playing === cutting.clipId)
|
|
1758
|
+
return;
|
|
1759
|
+
// Something else plays: the cutter waits for the next boundary.
|
|
1760
|
+
state = { ...state, cutting: undefined };
|
|
1761
|
+
return decideCommand(sessionId);
|
|
1762
|
+
}
|
|
1763
|
+
// Withdrawals the plan wants, retried once a refused one's session changes.
|
|
1764
|
+
for (const item of items.values())
|
|
1765
|
+
if (item.withdraw !== undefined &&
|
|
1766
|
+
item.clipId !== undefined &&
|
|
1767
|
+
item.sessionId === value.id &&
|
|
1768
|
+
(item.phase === "Building" || item.phase === "Ready") &&
|
|
1769
|
+
value.source?.available === true &&
|
|
1770
|
+
item.blockedRemove !== signature(value))
|
|
1771
|
+
return queueCommand(value.id, { _tag: "Remove", clipId: item.clipId });
|
|
1772
|
+
// A drain withdraws filler once nothing accepted still needs it to cover the wait.
|
|
1773
|
+
const fillerNeeded = state.drains.every((drain) => drain.finish === "accepted") &&
|
|
1774
|
+
[...items.values()].some((item) => live(item) &&
|
|
1775
|
+
item.mode !== "held" &&
|
|
1776
|
+
item.phase !== "Started" &&
|
|
1777
|
+
(item.phase !== "Ready" || (atMono(item) ?? -Infinity) > now.mono));
|
|
1778
|
+
if (state.drains.length > 0 && !fillerNeeded)
|
|
1779
|
+
for (const clip of readyOf(value))
|
|
1780
|
+
if (fillerRemovable(value, clip))
|
|
1781
|
+
return queueCommand(value.id, { _tag: "Remove", clipId: clip.clipId });
|
|
1782
|
+
// Order its Ready clips by rank. A move never ranks across sessions.
|
|
1783
|
+
const actual = readyOf(value);
|
|
1784
|
+
const desired = [...actual].sort((a, b) => compareRank(rankClip(a), rankClip(b)));
|
|
1785
|
+
const moved = actual.findIndex((clip, index) => clip.clipId !== desired[index].clipId);
|
|
1786
|
+
const misplaced = moved < 0 ? undefined : desired[moved];
|
|
1787
|
+
if (value.source?.available === true &&
|
|
1788
|
+
misplaced?.tag !== undefined &&
|
|
1789
|
+
value.blockedMove !== signature(value) + misplaced.clipId &&
|
|
1790
|
+
now.mono >= value.moveRetryAt)
|
|
1791
|
+
return queueCommand(value.id, { _tag: "Move", clipId: misplaced.clipId, position: moved });
|
|
1792
|
+
// A cut lane's Ready item at the front cuts a lower lane's clip, or filler, that has a while to run.
|
|
1793
|
+
const front = value.id === state.air ? actual[0] : undefined;
|
|
1794
|
+
const cutItem = itemOf(front);
|
|
1795
|
+
const playing = value.source?.playing;
|
|
1796
|
+
if (front !== undefined &&
|
|
1797
|
+
cutItem?.phase === "Ready" &&
|
|
1798
|
+
playing !== undefined &&
|
|
1799
|
+
state.cutting === undefined &&
|
|
1800
|
+
config.lanes[cutItem.spec.lane]?.cut === true &&
|
|
1801
|
+
!(state.cut?.sessionId === value.id && state.cut.clipId === playing.clipId) &&
|
|
1802
|
+
playingRestMs(value) > cutMarginMs) {
|
|
1803
|
+
// Only filler or a clip of a strictly lower lane is cut, never one of the cutter's lane or above.
|
|
1804
|
+
const playingItem = itemOf(playing);
|
|
1805
|
+
const lower = playing.tag?._tag === "Filler" ||
|
|
1806
|
+
(playingItem !== undefined && playingItem.spec.lane > cutItem.spec.lane);
|
|
1807
|
+
if (lower && airs(front)) {
|
|
1808
|
+
state = {
|
|
1809
|
+
...state,
|
|
1810
|
+
cut: { sessionId: value.id, clipId: playing.clipId },
|
|
1811
|
+
cutting: {
|
|
1812
|
+
sessionId: value.id,
|
|
1813
|
+
clipId: playing.clipId,
|
|
1814
|
+
next: front.clipId,
|
|
1815
|
+
stage: "stopping",
|
|
1816
|
+
},
|
|
1817
|
+
};
|
|
1818
|
+
return decideCommand(sessionId);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
// A held item about to be next, or an At item Ready too early, is removed: rebuilt later, or
|
|
1822
|
+
// dropped if withdrawn.
|
|
1823
|
+
for (const [index, clip] of actual.entries()) {
|
|
1824
|
+
const item = itemOf(clip);
|
|
1825
|
+
if (item?.phase !== "Ready")
|
|
1826
|
+
continue;
|
|
1827
|
+
const readyMs = readyAheadMs(value, index);
|
|
1828
|
+
const aheadMs = playingRestMs(value) + readyMs;
|
|
1829
|
+
const at = atMono(item);
|
|
1830
|
+
const exposed = item.mode === "held"
|
|
1831
|
+
? aheadMs < exposureMarginMs || now.mono >= (exposedAt(value, readyMs) ?? Infinity)
|
|
1832
|
+
: at !== undefined && at > now.mono && aheadMs < at - now.mono;
|
|
1833
|
+
if (exposed && value.source?.available === true) {
|
|
1834
|
+
// A withdrawal waiting on its session's queues to change is due now: its removal is asked
|
|
1835
|
+
// again, once, and drops the item. Refused again, it waits for a change after all.
|
|
1836
|
+
if (item.withdraw !== undefined) {
|
|
1837
|
+
if (item.discarded?.sessionId === value.id && item.discarded.clipId === clip.clipId)
|
|
1838
|
+
continue;
|
|
1839
|
+
set(item.spec.key, {
|
|
1840
|
+
blockedRemove: undefined,
|
|
1841
|
+
discarded: { sessionId: value.id, clipId: clip.clipId },
|
|
1842
|
+
});
|
|
1843
|
+
return queueCommand(value.id, { _tag: "Remove", clipId: clip.clipId });
|
|
1844
|
+
}
|
|
1845
|
+
// It goes back to the plan, to be built again once the air ahead covers its wait.
|
|
1846
|
+
set(item.spec.key, {
|
|
1847
|
+
phase: "Accepted",
|
|
1848
|
+
dispatchedAt: undefined,
|
|
1849
|
+
clipId: undefined,
|
|
1850
|
+
sessionId: undefined,
|
|
1851
|
+
covered: undefined,
|
|
1852
|
+
discarded: { sessionId: value.id, clipId: clip.clipId },
|
|
1853
|
+
});
|
|
1854
|
+
return queueCommand(value.id, { _tag: "Remove", clipId: clip.clipId });
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
// Build, on the session that takes new work: the first eligible item by build order, else
|
|
1858
|
+
// filler below its floor.
|
|
1859
|
+
const target = preferred();
|
|
1860
|
+
if (target?.id !== value.id ||
|
|
1861
|
+
!(state.accepting || state.drains.some((drain) => drain.finish === "accepted")))
|
|
1862
|
+
return;
|
|
1863
|
+
// An enqueue whose reply was lost holds no build slot: H3 builds in order, so if it landed it
|
|
1864
|
+
// builds ahead of what follows, and if it did not, nothing would free the slot but the
|
|
1865
|
+
// unknown timeout. The cap check still counts it, since it may yet take air here.
|
|
1866
|
+
const inFlight = [...items.values()].filter((item) => item.phase === "Building" && item.sessionId === target.id).length + target.source.building.filter((clip) => clip.tag?._tag === "Filler").length;
|
|
1867
|
+
if (inFlight >= config.maxBuildsInFlight)
|
|
1868
|
+
return;
|
|
1869
|
+
const room = runway();
|
|
1870
|
+
const floorSeconds = fillerFloor();
|
|
1871
|
+
const drainingNeeds = state.drains.length === 0 || fillerNeeded;
|
|
1872
|
+
for (const item of eligible(floorSeconds)) {
|
|
1873
|
+
// What cannot air before this session's cap waits for its replacement, and so does what
|
|
1874
|
+
// follows it, which would otherwise air ahead of it.
|
|
1875
|
+
if (!fits(target, item.spec.seconds))
|
|
1876
|
+
break;
|
|
1877
|
+
const from = item.spec.continuity
|
|
1878
|
+
? continuation(item, target)
|
|
1879
|
+
: { _tag: "from", clipId: undefined, follows: undefined };
|
|
1880
|
+
if (from._tag === "wait")
|
|
1881
|
+
continue;
|
|
1882
|
+
if (drainingNeeds && coverFirst(target, item, from.clipId !== undefined, room))
|
|
1883
|
+
return;
|
|
1884
|
+
set(item.spec.key, {
|
|
1885
|
+
phase: "Building",
|
|
1886
|
+
sessionId: target.id,
|
|
1887
|
+
dispatchedAt: now.mono,
|
|
1888
|
+
retryAt: undefined,
|
|
1889
|
+
continued: from.clipId !== undefined,
|
|
1890
|
+
follows: from.follows,
|
|
1891
|
+
});
|
|
1892
|
+
return queueCommand(target.id, {
|
|
1893
|
+
_tag: "Enqueue",
|
|
1894
|
+
request: item.spec.request,
|
|
1895
|
+
tag: { _tag: "Item", key: item.spec.key },
|
|
1896
|
+
continueFrom: from.clipId,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
const filler = config.filler;
|
|
1900
|
+
if (filler === undefined || !fillerFree())
|
|
1901
|
+
return;
|
|
1902
|
+
const anchorGap = Math.max(0, ...[...items.values()].flatMap((item) => {
|
|
1903
|
+
const at = atMono(item);
|
|
1904
|
+
return live(item) && at !== undefined && at > now.mono ? [(at - now.mono) / 1000] : [];
|
|
1905
|
+
}));
|
|
1906
|
+
const targetSeconds = Math.max(filler.target, floorSeconds);
|
|
1907
|
+
const refilling = anchorGap > room || (state.filler.refilling ? below(targetSeconds) : below(floorSeconds));
|
|
1908
|
+
state = { ...state, filler: { ...state.filler, refilling } };
|
|
1909
|
+
if (!refilling || (!below(targetSeconds) && room >= anchorGap) || !drainingNeeds)
|
|
1910
|
+
return;
|
|
1911
|
+
const seconds = fillLength(anchorGap - room, filler.lengths, estimates().length);
|
|
1912
|
+
if (!fits(target, fillerLength(seconds)))
|
|
1913
|
+
return;
|
|
1914
|
+
sendFiller(filler, target, seconds, room);
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* `item`'s p95 build in seconds, at the continued rate if it continues a
|
|
1918
|
+
* clip. Unknown until three builds were measured.
|
|
1919
|
+
*/
|
|
1920
|
+
function buildOf(item, continued) {
|
|
1921
|
+
const p95 = estimates().build?.p95;
|
|
1922
|
+
if (p95 === undefined)
|
|
1923
|
+
return undefined;
|
|
1924
|
+
return (continued ? (continuedBuildRate(state.samples) ?? p95) : p95) * item.spec.seconds;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Protecting the air, sends a filler clip ahead of `item` when its p95 build
|
|
1928
|
+
* would outlast the air secured. The clip is long enough, within the filler's
|
|
1929
|
+
* lengths, that the air it adds less what its own build drains covers the
|
|
1930
|
+
* rest and a margin, but airs no longer than the item builds, which a longer
|
|
1931
|
+
* clip would only delay, unless the shortest the filler takes is longer. An
|
|
1932
|
+
* item waits for one such clip at most, and for none that cannot be sent
|
|
1933
|
+
* now, would add no air or build no sooner than the item, or would not air
|
|
1934
|
+
* before the session's cap. A shortfall within the margin alone is left to
|
|
1935
|
+
* the floor, which refills without holding the item.
|
|
1936
|
+
*/
|
|
1937
|
+
function coverFirst(target, item, continued, room) {
|
|
1938
|
+
const filler = config.filler;
|
|
1939
|
+
if (filler === undefined || !protects(item) || item.covered === true || !fillerFree())
|
|
1940
|
+
return false;
|
|
1941
|
+
const build = buildOf(item, continued);
|
|
1942
|
+
const rate = estimates().build?.p95;
|
|
1943
|
+
if (build === undefined || rate === undefined)
|
|
1944
|
+
return false;
|
|
1945
|
+
const dark = build - securedOf({ ...state, items }, now);
|
|
1946
|
+
const short = dark + lookaheadMarginSeconds;
|
|
1947
|
+
// A requested second airs as long as asked, or as a provider that cuts clips short leaves it.
|
|
1948
|
+
const airs = Math.min(1, estimates().length);
|
|
1949
|
+
if (dark <= 0 || airs <= rate)
|
|
1950
|
+
return false;
|
|
1951
|
+
// Sized as if the clip's build starts now and the item's once it ends, as with one build in
|
|
1952
|
+
// flight, the default. With more, a build already in flight here goes ahead of both, and
|
|
1953
|
+
// neither the air it drains meanwhile nor the air it adds is counted.
|
|
1954
|
+
const seconds = Math.min(filler.lengths.max, Math.max(filler.lengths.min, Math.min(short / (airs - rate), build / airs)));
|
|
1955
|
+
// A refused clip goes again as it was asked for: it must build sooner and fit at its length.
|
|
1956
|
+
const length = fillerLength(seconds);
|
|
1957
|
+
if (rate * length >= build || !fits(target, length))
|
|
1958
|
+
return false;
|
|
1959
|
+
set(item.spec.key, { covered: true });
|
|
1960
|
+
sendFiller(filler, target, seconds, room);
|
|
1961
|
+
return true;
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Whether filler protects the air ahead of `item`'s build. What has a time to
|
|
1965
|
+
* meet goes as soon as it may: filler ahead would only make it later, and a
|
|
1966
|
+
* firm one late enough to be dropped. An `At` start or a `startBy` names that
|
|
1967
|
+
* time; an `Asap` start, a released `Manual` item, which airs as one, or a
|
|
1968
|
+
* lane that cuts makes it now.
|
|
1969
|
+
*/
|
|
1970
|
+
function protects(item) {
|
|
1971
|
+
return (config.filler?.protect === "air" &&
|
|
1972
|
+
item.startBy === undefined &&
|
|
1973
|
+
item.spec.start._tag !== "At" &&
|
|
1974
|
+
item.mode !== "asap" &&
|
|
1975
|
+
config.lanes[item.spec.lane]?.cut !== true);
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Whether a filler clip may be sent: none waits out a refused one. Each clip takes its index
|
|
1979
|
+
* as its enqueue goes out, so each lane may carry one, and none waits for another's.
|
|
1980
|
+
*/
|
|
1981
|
+
function fillerFree() {
|
|
1982
|
+
return now.mono >= state.filler.retryAt;
|
|
1983
|
+
}
|
|
1984
|
+
/** Asks for the filler clip `index` again as it was, unless moderation flagged it. */
|
|
1985
|
+
function retryFiller(index, request) {
|
|
1986
|
+
if (state.filler.flagged.includes(index))
|
|
1987
|
+
return;
|
|
1988
|
+
const retries = [...state.filler.retries, { index, request }].sort((a, b) => a.index - b.index);
|
|
1989
|
+
state = { ...state, filler: { ...state.filler, retries } };
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Seconds the next filler clip asks for: a refused one is asked for again as it was, at its own
|
|
1993
|
+
* length, and a new one for `seconds`.
|
|
1994
|
+
*/
|
|
1995
|
+
function fillerLength(seconds) {
|
|
1996
|
+
const request = state.filler.retries[0]?.request;
|
|
1997
|
+
return request === undefined ? seconds : (request.seconds ?? requestSeconds.min);
|
|
1998
|
+
}
|
|
1999
|
+
/** Sends the next filler clip to `target`, a new one asked for `seconds`. */
|
|
2000
|
+
function sendFiller(filler, target, seconds, room) {
|
|
2001
|
+
const [retry, ...retries] = state.filler.retries;
|
|
2002
|
+
const index = retry?.index ?? state.filler.index;
|
|
2003
|
+
const request = retry?.request ?? filler.clip({ index, runwaySeconds: room, seconds });
|
|
2004
|
+
const invalid = filler.invalid(request, index);
|
|
2005
|
+
if (invalid !== undefined) {
|
|
2006
|
+
// Skipping it would leave the air uncovered without a word, and asking again gets the same.
|
|
2007
|
+
state = { ...state, filler: { ...state.filler, retryAt: Infinity } };
|
|
2008
|
+
actions.push({ _tag: "Fail", reason: invalid, cause: "filler", index });
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
state = {
|
|
2012
|
+
...state,
|
|
2013
|
+
filler: {
|
|
2014
|
+
...state.filler,
|
|
2015
|
+
...(retry === undefined ? { index: index + 1 } : { retries }),
|
|
2016
|
+
},
|
|
2017
|
+
};
|
|
2018
|
+
updateSession(target.id, {
|
|
2019
|
+
fillerSent: { at: now.mono, seconds: request.seconds ?? requestSeconds.min },
|
|
2020
|
+
});
|
|
2021
|
+
queueCommand(target.id, { _tag: "Enqueue", request, tag: { _tag: "Filler", index } });
|
|
2022
|
+
}
|
|
2023
|
+
/** Items that may be built now, first in build order. */
|
|
2024
|
+
function eligible(floorSeconds) {
|
|
2025
|
+
const target = preferred();
|
|
2026
|
+
return [...items.values()]
|
|
2027
|
+
.filter((item) => item.phase === "Accepted" &&
|
|
2028
|
+
item.withdraw === undefined &&
|
|
2029
|
+
previousAdmitted(item) &&
|
|
2030
|
+
(item.retryAt ?? -Infinity) <= now.mono &&
|
|
2031
|
+
// A session that refused it unsent gets it again only once what it reports has changed.
|
|
2032
|
+
(item.unsent === undefined ||
|
|
2033
|
+
item.unsent.sessionId !== target?.id ||
|
|
2034
|
+
item.unsent.changes !== target.changes) &&
|
|
2035
|
+
(item.notBefore ?? -Infinity) <= now.mono &&
|
|
2036
|
+
// Autoplay cannot hold a Ready clip: build a future anchor once air ahead covers the wait.
|
|
2037
|
+
covered(item) &&
|
|
2038
|
+
// A held item is built ahead only while filler keeps the runway at its floor.
|
|
2039
|
+
(item.mode !== "held" || (floorSeconds > 0 && !below(floorSeconds))))
|
|
2040
|
+
.sort(buildOrder);
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Whether the air secured lasts to an `At` item's time, so that its build may go. Once the
|
|
2044
|
+
* runway stops falling, the item's time comes within it, at `coveredAt`.
|
|
2045
|
+
*/
|
|
2046
|
+
function covered(item) {
|
|
2047
|
+
const at = atMono(item);
|
|
2048
|
+
if (at === undefined || at <= now.mono)
|
|
2049
|
+
return true;
|
|
2050
|
+
const { end, seconds } = runwayTerms();
|
|
2051
|
+
return Math.max(end, now.mono) + seconds * 1000 >= at || now.mono >= at - seconds * 1000;
|
|
2052
|
+
}
|
|
2053
|
+
function coveredAt(item) {
|
|
2054
|
+
const at = atMono(item);
|
|
2055
|
+
return at === undefined || covered(item) ? undefined : at - runwayTerms().seconds * 1000;
|
|
2056
|
+
}
|
|
2057
|
+
/** Milliseconds of Ready air ahead of `value`'s Ready clip at `index`, less what plays. */
|
|
2058
|
+
function readyAheadMs(value, index) {
|
|
2059
|
+
return readyOf(value)
|
|
2060
|
+
.slice(0, index)
|
|
2061
|
+
.filter(airs)
|
|
2062
|
+
.reduce((total, other) => total + other.seconds * 1000, 0);
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* When a held item Ready on `value` behind `aheadMs` of Ready air comes within the margin of
|
|
2066
|
+
* airing, as the clip playing there airs; undefined if it doesn't before that clip ends.
|
|
2067
|
+
*/
|
|
2068
|
+
function exposedAt(value, aheadMs) {
|
|
2069
|
+
const end = playingEndOf(value);
|
|
2070
|
+
return end === undefined || aheadMs >= exposureMarginMs
|
|
2071
|
+
? undefined
|
|
2072
|
+
: end + aheadMs - exposureMarginMs;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Whether a clip of `seconds` built on `target` now would finish airing before
|
|
2076
|
+
* the session's cap, counting everything that airs ahead of it there: the
|
|
2077
|
+
* playing clip's rest, its Ready clips, its builds in flight and, for a
|
|
2078
|
+
* replacement, what the session on air still has. A clip no fresh session
|
|
2079
|
+
* could air whole is not held back.
|
|
2080
|
+
*/
|
|
2081
|
+
function fits(target, seconds) {
|
|
2082
|
+
if (target.lifetimeMs === Infinity)
|
|
2083
|
+
return true;
|
|
2084
|
+
const ratio = estimates().length;
|
|
2085
|
+
const lengthMs = seconds * ratio * 1000;
|
|
2086
|
+
const marginMs = lookaheadMarginSeconds * 1000;
|
|
2087
|
+
if (lengthMs > target.lifetimeMs - marginMs)
|
|
2088
|
+
return true;
|
|
2089
|
+
const queuedMs = (value) => readyOf(value)
|
|
2090
|
+
.filter(airs)
|
|
2091
|
+
.reduce((total, clip) => total + clip.seconds * 1000, 0);
|
|
2092
|
+
const onAir = session(state.air);
|
|
2093
|
+
const startsAt = target.id === state.air || onAir === undefined
|
|
2094
|
+
? now.mono + playingRestMs(target)
|
|
2095
|
+
: now.mono + playingRestMs(onAir) + queuedMs(onAir);
|
|
2096
|
+
const buildingMs = [...items.values()]
|
|
2097
|
+
.filter((item) => item.sessionId === target.id && (item.phase === "Building" || item.phase === "Unknown"))
|
|
2098
|
+
.reduce((total, item) => total + item.spec.seconds * ratio * 1000, 0) +
|
|
2099
|
+
(target.source?.building ?? [])
|
|
2100
|
+
.filter((clip) => clip.tag?._tag === "Filler")
|
|
2101
|
+
.reduce((total, clip) => total + clip.seconds * 1000, 0);
|
|
2102
|
+
return (startsAt + queuedMs(target) + buildingMs + lengthMs <=
|
|
2103
|
+
target.openedAt + target.lifetimeMs - marginMs);
|
|
2104
|
+
}
|
|
2105
|
+
/** The runway filler refills below; protecting the air, it covers the next item's build too. */
|
|
2106
|
+
function fillerFloor() {
|
|
2107
|
+
const floor = clipFloor();
|
|
2108
|
+
if (floor <= 0 || config.filler?.protect !== "air")
|
|
2109
|
+
return floor;
|
|
2110
|
+
const next = eligible(floor)[0];
|
|
2111
|
+
const build = next === undefined || !protects(next) ? undefined : buildOf(next, next.spec.continuity);
|
|
2112
|
+
return build === undefined ? floor : Math.max(floor, build + lookaheadMarginSeconds);
|
|
2113
|
+
}
|
|
2114
|
+
function clipFloor() {
|
|
2115
|
+
const filler = config.filler;
|
|
2116
|
+
if (filler === undefined || filler.floor <= 0)
|
|
2117
|
+
return 0;
|
|
2118
|
+
const p95 = estimates().build?.p95;
|
|
2119
|
+
// A floor covers one p95 build of the next filler clip, so a refill started there is Ready in time.
|
|
2120
|
+
return p95 === undefined
|
|
2121
|
+
? filler.floor
|
|
2122
|
+
: Math.max(filler.floor, p95 * filler.lengths.min + lookaheadMarginSeconds);
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* What a continuing item continues from: the clip that airs just before it on
|
|
2126
|
+
* the same session, or the one that will by the time it is built
|
|
2127
|
+
* (`airsBefore`), if the provider still offers it. It waits while that
|
|
2128
|
+
* predecessor is an item not built yet; there is nothing to continue across sessions.
|
|
2129
|
+
*/
|
|
2130
|
+
function continuation(item, target) {
|
|
2131
|
+
const place = rankItem(item);
|
|
2132
|
+
const before = (rank) => compareRank(rank, place) < 0;
|
|
2133
|
+
let best;
|
|
2134
|
+
for (const clip of readyOf(target)) {
|
|
2135
|
+
const rank = rankClip(clip);
|
|
2136
|
+
const owner = itemOf(clip);
|
|
2137
|
+
// What this item replaces, or a batch is taking off, is no predecessor.
|
|
2138
|
+
if (before(rank) &&
|
|
2139
|
+
owner?.withdraw === undefined &&
|
|
2140
|
+
(owner === undefined || (!superseded.has(owner.spec.key) && replacedOf(item) !== owner)) &&
|
|
2141
|
+
(best === undefined || compareRank(rank, best.rank) > 0))
|
|
2142
|
+
best = { rank, clipId: clip.clipId };
|
|
2143
|
+
}
|
|
2144
|
+
for (const other of items.values())
|
|
2145
|
+
if (other !== item &&
|
|
2146
|
+
other.withdraw === undefined &&
|
|
2147
|
+
!superseded.has(other.spec.key) &&
|
|
2148
|
+
replacedOf(item) !== other &&
|
|
2149
|
+
(other.phase === "Accepted" || other.phase === "Building" || other.phase === "Unknown")) {
|
|
2150
|
+
const rank = rankItem(other);
|
|
2151
|
+
if (before(rank) && (best === undefined || compareRank(rank, best.rank) > 0))
|
|
2152
|
+
best = { rank, clipId: undefined };
|
|
2153
|
+
}
|
|
2154
|
+
if (best !== undefined && best.clipId === undefined)
|
|
2155
|
+
return { _tag: "wait" };
|
|
2156
|
+
const predecessor = best?.clipId ?? (target.id === state.air ? target.source?.playing?.clipId : undefined);
|
|
2157
|
+
if (predecessor === undefined)
|
|
2158
|
+
return { _tag: "from", clipId: undefined };
|
|
2159
|
+
const from = airsBefore(item, predecessor, target);
|
|
2160
|
+
return target.source?.continuable.includes(from.clipId) === true
|
|
2161
|
+
? { _tag: "from", ...from }
|
|
2162
|
+
: { _tag: "from", clipId: undefined };
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* The clip that airs just before `item` if its continued build starts now:
|
|
2166
|
+
* `predecessor`, unless that build is projected Ready only after
|
|
2167
|
+
* `predecessor` ends. The Ready clips behind it then air first, and the item
|
|
2168
|
+
* continues from, and follows, the one that will be playing when it is Ready,
|
|
2169
|
+
* or the last of them. A clip still to be built is never chosen: H3 continues
|
|
2170
|
+
* only from a clip that finished generating.
|
|
2171
|
+
*/
|
|
2172
|
+
function airsBefore(item, predecessor, target) {
|
|
2173
|
+
const rate = continuedBuildRate(state.samples);
|
|
2174
|
+
if (rate === undefined)
|
|
2175
|
+
return { clipId: predecessor };
|
|
2176
|
+
const readyAt = now.mono + (rate * item.spec.seconds + lookaheadMarginSeconds) * 1000;
|
|
2177
|
+
const onAir = target.id === state.air ? target.source?.playing : undefined;
|
|
2178
|
+
const queued = readyOf(target)
|
|
2179
|
+
.filter(airs)
|
|
2180
|
+
.sort((a, b) => compareRank(rankClip(a), rankClip(b)));
|
|
2181
|
+
const after = onAir?.clipId === predecessor
|
|
2182
|
+
? 0
|
|
2183
|
+
: queued.findIndex((clip) => clip.clipId === predecessor) + 1;
|
|
2184
|
+
if (after === 0 && onAir?.clipId !== predecessor)
|
|
2185
|
+
return { clipId: predecessor };
|
|
2186
|
+
let end = now.mono +
|
|
2187
|
+
(onAir === undefined ? 0 : playingRestMs(target)) +
|
|
2188
|
+
queued.slice(0, after).reduce((total, clip) => total + clip.seconds * 1000, 0);
|
|
2189
|
+
if (readyAt <= end)
|
|
2190
|
+
return { clipId: predecessor };
|
|
2191
|
+
const behind = queued.slice(after);
|
|
2192
|
+
for (const [index, clip] of behind.entries()) {
|
|
2193
|
+
end += clip.seconds * 1000;
|
|
2194
|
+
if (readyAt <= end || index === behind.length - 1)
|
|
2195
|
+
return { clipId: clip.clipId, follows: clip.clipId };
|
|
2196
|
+
}
|
|
2197
|
+
return { clipId: predecessor };
|
|
2198
|
+
}
|
|
2199
|
+
function wake() {
|
|
2200
|
+
const times = [];
|
|
2201
|
+
const later = (at) => {
|
|
2202
|
+
if (at !== undefined && at > now.mono && Number.isFinite(at))
|
|
2203
|
+
times.push(at);
|
|
2204
|
+
};
|
|
2205
|
+
for (const item of items.values()) {
|
|
2206
|
+
const at = atMono(item);
|
|
2207
|
+
// A settled item's clip may still be listed Ready until a read shows it gone: its time
|
|
2208
|
+
// still ranks it there.
|
|
2209
|
+
later(at);
|
|
2210
|
+
if (item.phase === "Settled")
|
|
2211
|
+
continue;
|
|
2212
|
+
later(item.notBefore);
|
|
2213
|
+
later(item.startBy);
|
|
2214
|
+
later(item.retryAt);
|
|
2215
|
+
if (at !== undefined &&
|
|
2216
|
+
item.spec.start._tag === "At" &&
|
|
2217
|
+
typeof item.spec.start.late === "object")
|
|
2218
|
+
later(at + item.spec.start.late.skipAfterMs);
|
|
2219
|
+
if (item.unknownSince !== undefined)
|
|
2220
|
+
later(item.unknownSince + config.unknownTimeoutMs);
|
|
2221
|
+
if (item.phase === "Started")
|
|
2222
|
+
for (const [index, cue] of item.spec.cues.entries())
|
|
2223
|
+
if (!item.fired.includes(index))
|
|
2224
|
+
later(cueAt(item, cue));
|
|
2225
|
+
if (item.phase === "Accepted")
|
|
2226
|
+
later(coveredAt(item));
|
|
2227
|
+
if (lateWhenProjected(item))
|
|
2228
|
+
later(item.startBy - projection(item).after);
|
|
2229
|
+
}
|
|
2230
|
+
for (const value of state.sessions) {
|
|
2231
|
+
for (const entry of value.unknownFiller)
|
|
2232
|
+
later(entry.since + config.unknownTimeoutMs);
|
|
2233
|
+
later(value.openedAt + value.lifetimeMs - config.leadMs);
|
|
2234
|
+
later(value.openedAt + value.lifetimeMs);
|
|
2235
|
+
if (value.lastEndedAt !== undefined)
|
|
2236
|
+
later(value.lastEndedAt + config.graceMs);
|
|
2237
|
+
later(value.autoplayRetry?.at);
|
|
2238
|
+
later(value.moveRetryAt);
|
|
2239
|
+
for (const [index, clip] of readyOf(value).entries()) {
|
|
2240
|
+
const item = itemOf(clip);
|
|
2241
|
+
if (item?.phase === "Ready" && item.mode === "held")
|
|
2242
|
+
later(exposedAt(value, readyAheadMs(value, index)));
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
if (!state.openingPaused)
|
|
2246
|
+
later(state.openRetryAt);
|
|
2247
|
+
later(state.filler.retryAt);
|
|
2248
|
+
// The runway falls while a clip plays: wake as it falls to the filler's floor, which,
|
|
2249
|
+
// protecting the air, also covers the next item's build. Below it, filler goes at once.
|
|
2250
|
+
later(fallsTo(clipFloor()));
|
|
2251
|
+
later(fallsTo(fillerFloor()));
|
|
2252
|
+
return times.length === 0 ? undefined : Math.min(...times);
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
2255
|
+
/**
|
|
2256
|
+
* Applies one input: a pure function of the state, the input, the configuration and the time.
|
|
2257
|
+
* A decision can make another due at the same instant, as a build sent can leave a firm item
|
|
2258
|
+
* projected late, or a session lost at its cap settle what a batch waits on: the plan looks
|
|
2259
|
+
* again until nothing more is due, so none of it waits for a later input or wake.
|
|
2260
|
+
*/
|
|
2261
|
+
export const step = dual(4, (config, previous, input, now) => {
|
|
2262
|
+
let result = decide(config, previous, input, now);
|
|
2263
|
+
for (let look = 1, decided = result.actions.length; decided > 0 && look < maxLooks; look++) {
|
|
2264
|
+
const again = decide(config, result.state, { _tag: "Tick" }, now);
|
|
2265
|
+
result = { ...again, actions: [...result.actions, ...again.actions] };
|
|
2266
|
+
decided = again.actions.length;
|
|
2267
|
+
}
|
|
2268
|
+
return result;
|
|
2269
|
+
});
|
|
2270
|
+
/**
|
|
2271
|
+
* What a withdrawal of `key` finds once nothing can change: the recorded fate of
|
|
2272
|
+
* the item, or of a group's parts, answered as a withdrawal waiting on them would be.
|
|
2273
|
+
*/
|
|
2274
|
+
export const fate = dual(2, (state, key) => combined((state.groups.get(key)?.parts ?? [key]).flatMap((part) => {
|
|
2275
|
+
const item = state.items.get(part);
|
|
2276
|
+
return item === undefined ? [] : [fateOf(item)];
|
|
2277
|
+
})));
|
|
2278
|
+
/**
|
|
2279
|
+
* Equal clips that tile the gap to an anchor within the provider's lengths; else the shortest.
|
|
2280
|
+
* A tile that falls short costs a whole clip more, so each asks for no less than its share. A
|
|
2281
|
+
* provider that makes clips longer than asked covers the share anyway: H3 aligns a length up to
|
|
2282
|
+
* its frame grid, which airs 5 s as 5.167 s but 5.806 s as 5.875 s, so a ratio learned on one
|
|
2283
|
+
* length does not carry to another. Only one that makes them shorter is asked for more.
|
|
2284
|
+
*/
|
|
2285
|
+
const fillLength = (gapSeconds, lengths, ratio) => {
|
|
2286
|
+
if (!(gapSeconds > 0))
|
|
2287
|
+
return lengths.min;
|
|
2288
|
+
const shortfall = Math.min(1, ratio);
|
|
2289
|
+
const pieces = Math.max(1, Math.ceil(gapSeconds / (lengths.max * shortfall)));
|
|
2290
|
+
return Math.min(lengths.max, Math.max(lengths.min, gapSeconds / pieces / shortfall));
|
|
2291
|
+
};
|
|
2292
|
+
/** The public view of the plan. */
|
|
2293
|
+
export const view = dual(3, (config, state, now) => {
|
|
2294
|
+
const own = (clip) => clip.tag?._tag === "Item" ? clip.tag.key : clip.tag?._tag === "Filler" ? "filler" : "other";
|
|
2295
|
+
const playing = state.sessions.find((value) => value.id === state.air)?.playing;
|
|
2296
|
+
return {
|
|
2297
|
+
accepting: state.accepting,
|
|
2298
|
+
runwaySeconds: securedOf(state, now),
|
|
2299
|
+
playing: playing === undefined
|
|
2300
|
+
? null
|
|
2301
|
+
: { key: own(playing), startedAt: playing.wall, seconds: playing.seconds },
|
|
2302
|
+
lanes: config.lanes.map((lane, index) => ({
|
|
2303
|
+
name: lane.name,
|
|
2304
|
+
keys: [...state.items.values()]
|
|
2305
|
+
.filter((item) => item.spec.lane === index && item.phase !== "Settled")
|
|
2306
|
+
.sort((a, b) => a.order - b.order)
|
|
2307
|
+
.map((item) => item.spec.key),
|
|
2308
|
+
})),
|
|
2309
|
+
sessions: state.sessions.map((value) => ({
|
|
2310
|
+
sessionId: value.id,
|
|
2311
|
+
role: value.id === state.air ? "on-air" : value.retiring ? "retiring" : "replacement",
|
|
2312
|
+
ready: (value.source?.ready ?? []).map(own),
|
|
2313
|
+
})),
|
|
2314
|
+
starved: state.starved,
|
|
2315
|
+
estimates: estimatesOf(state.samples),
|
|
2316
|
+
};
|
|
2317
|
+
});
|
|
2318
|
+
/** The status of an item nothing can settle any more. */
|
|
2319
|
+
export const indeterminate = { _tag: "Unknown", terminal: true };
|
|
2320
|
+
/** What an as-run status settles: the item's start, its outcome, or both. */
|
|
2321
|
+
export const decides = (status) => {
|
|
2322
|
+
switch (status._tag) {
|
|
2323
|
+
case "Started":
|
|
2324
|
+
return { started: status };
|
|
2325
|
+
// An item's start is always published before its end.
|
|
2326
|
+
case "Ended":
|
|
2327
|
+
return { outcome: status };
|
|
2328
|
+
case "Dropped":
|
|
2329
|
+
case "Failed":
|
|
2330
|
+
case "Unobserved":
|
|
2331
|
+
return { started: status, outcome: status };
|
|
2332
|
+
case "Unknown":
|
|
2333
|
+
return status.terminal === true ? { started: indeterminate, outcome: indeterminate } : {};
|
|
2334
|
+
default:
|
|
2335
|
+
return {};
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
//# sourceMappingURL=policy.js.map
|