@torrent-tv/proxy 2.14.3 → 2.15.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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 2.15.0
|
|
2
|
+
|
|
3
|
+
- **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
|
|
4
|
+
- **Fix**: The audio track a viewer leaves is stopped, and a seek reaches only the track being listened to. Each track is an ffmpeg process AND a reader holding pieces of the torrent, and one viewer who had changed track once had three readers on one file — picture, the track chosen and the track left. At a seek all three revived their windows at once, every resident piece was pinned, a read ended with zero bytes, ffmpeg read that as the end of the file, and every encoder died; the sessions answered 500 to everything after that until the viewer gave up.
|
|
5
|
+
- **Fix**: A read waits for a piece to be released instead of failing outright. Every resident piece being read at once is not a permanent condition — a pin lasts one read of one piece — so the store now waits for one, and a released pin wakes whoever is waiting. Failing there ended a read with zero bytes, which is indistinguishable from the end of the file to the process reading it. A five-second deadline keeps a genuine deadlock visible, and the wait re-checks on a timer: waiting on events alone hung, because when everything is pinned and nothing else is in flight there is no event left to fire — it hung this store's own test for the ten minutes a run is allowed.
|
|
6
|
+
|
|
1
7
|
## 2.14.3
|
|
2
8
|
|
|
3
9
|
- **Fix**: A separately published audio track begins where the PICTURE is, measured rather than guessed. The position this class keeps is the read head, and the viewer sits behind it by whatever the player has buffered — a figure the browser already reports with every link report, so the playhead is one subtraction away (less one segment of margin, since the report can be ten seconds old). 2.14.2 subtracted the whole look-ahead instead, which was safe but made the encoder produce up to two minutes of audio nobody would hear before reaching the part that was wanted. A report older than fifteen seconds is ignored — a viewer may have seeked since — and then the whole look-ahead is subtracted as before.
|
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GET /transcode/:sessionId/a/:track/warm?position=<seconds> — prepare an
|
|
3
|
+
* audio track before the player is told to change to it.
|
|
4
|
+
*
|
|
5
|
+
* The picture keeps playing while a quality rung is warmed (`variant-warm`),
|
|
6
|
+
* and a track change deserves the same: the player discards the audio it holds
|
|
7
|
+
* the moment it switches and cannot show a frame until the new track covers the
|
|
8
|
+
* playhead, so switching first and producing second shows the track's cold
|
|
9
|
+
* start as a spinner over a stopped picture (measured 2026-08-15).
|
|
10
|
+
*
|
|
11
|
+
* Answers 204 when the segment at that position is ready, so the caller can
|
|
12
|
+
* switch into bytes that already exist; 503 while it is still being made.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { waitForSessionFile } from "../session-file/get.js";
|
|
16
|
+
|
|
17
|
+
/** How long to hold the request before telling the caller to retry. */
|
|
18
|
+
const WARM_WAIT_MS = 12_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {import("fastify").FastifyRequest} req
|
|
22
|
+
* @param {import("fastify").FastifyReply} reply
|
|
23
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
24
|
+
*/
|
|
25
|
+
export async function handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager }) {
|
|
26
|
+
const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
27
|
+
const trackIndex = Number(req.params.track);
|
|
28
|
+
const positionSeconds = Number(req.query?.position);
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
!Number.isInteger(trackIndex) ||
|
|
32
|
+
trackIndex < 0 ||
|
|
33
|
+
!Number.isFinite(positionSeconds) ||
|
|
34
|
+
positionSeconds < 0
|
|
35
|
+
) {
|
|
36
|
+
return reply.code(400).send({ error: "A track index and a non-negative position are required." });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let prepared;
|
|
40
|
+
try {
|
|
41
|
+
prepared = await hlsSessionManager.prepareAudioTrack(baseSessionId, trackIndex, positionSeconds);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
+
reply.header("Retry-After", "1");
|
|
45
|
+
return reply.code(503).send({ error: `Could not prepare the audio track: ${message}` });
|
|
46
|
+
}
|
|
47
|
+
if (!prepared) {
|
|
48
|
+
return reply.code(404).send({ error: "No such audio track for this transcode session." });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = await waitForSessionFile(
|
|
52
|
+
hlsSessionManager,
|
|
53
|
+
prepared.sessionId,
|
|
54
|
+
prepared.fileName,
|
|
55
|
+
WARM_WAIT_MS
|
|
56
|
+
);
|
|
57
|
+
if (result.kind === "file") {
|
|
58
|
+
// The bytes are the player's to fetch; the handle opened to reach them is
|
|
59
|
+
// ours to close, or a long-running proxy walks to EMFILE one track change
|
|
60
|
+
// at a time.
|
|
61
|
+
result.stream?.destroy?.();
|
|
62
|
+
return reply.code(204).send();
|
|
63
|
+
}
|
|
64
|
+
if (result.kind === "failed") {
|
|
65
|
+
return reply.code(500).send({ error: result.message });
|
|
66
|
+
}
|
|
67
|
+
// Still being produced. The caller may switch anyway — it will wait where it
|
|
68
|
+
// would have waited before — or ask again.
|
|
69
|
+
reply.header("Retry-After", "1");
|
|
70
|
+
return reply.code(503).send({ error: "The audio track is still warming up." });
|
|
71
|
+
}
|
package/server.js
CHANGED
|
@@ -32,6 +32,7 @@ import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/g
|
|
|
32
32
|
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
33
33
|
import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
|
|
34
34
|
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
35
|
+
import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
|
|
35
36
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
36
37
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
37
38
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
@@ -249,6 +250,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
249
250
|
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
250
251
|
handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
|
|
251
252
|
);
|
|
253
|
+
app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
|
|
254
|
+
handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager })
|
|
255
|
+
);
|
|
252
256
|
app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
|
|
253
257
|
handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
|
|
254
258
|
);
|
|
@@ -3720,7 +3720,16 @@ export class HlsSessionManager {
|
|
|
3720
3720
|
// treated as a seek anywhere in this class, so after a forward jump the
|
|
3721
3721
|
// audio would be held, refused, and left grinding forward from where it
|
|
3722
3722
|
// was — the picture playing over silence for as long as the jump was.
|
|
3723
|
-
|
|
3723
|
+
// Only the track being LISTENED to. A track the viewer left keeps its place
|
|
3724
|
+
// but not an encoder, and seeking it would start one for nobody — which is
|
|
3725
|
+
// how a single viewer came to have three ffmpeg processes and three readers
|
|
3726
|
+
// on one file (2026-08-15), enough to pin every resident piece and kill the
|
|
3727
|
+
// session outright.
|
|
3728
|
+
const listening = named.activeAudioTrackIndex ?? named.audioTrackIndex;
|
|
3729
|
+
for (const [trackIndex, renditionId] of named.audioRenditionSessions ?? []) {
|
|
3730
|
+
if (trackIndex !== listening) {
|
|
3731
|
+
continue;
|
|
3732
|
+
}
|
|
3724
3733
|
const rendition = this.sessionsById.get(renditionId);
|
|
3725
3734
|
if (rendition && rendition.state !== "disposed") {
|
|
3726
3735
|
rendition.lastAccessedAt = Date.now();
|
|
@@ -5129,6 +5138,54 @@ export class HlsSessionManager {
|
|
|
5129
5138
|
* @param {number} positionSeconds - Where the switch will happen.
|
|
5130
5139
|
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
5131
5140
|
*/
|
|
5141
|
+
/**
|
|
5142
|
+
* Prepare an audio track at a position, so a change of track is instant.
|
|
5143
|
+
*
|
|
5144
|
+
* The player, told to change track, discards the audio it holds and cannot
|
|
5145
|
+
* show a frame until the new track covers the playhead — so switching first
|
|
5146
|
+
* and producing second puts the whole of the track's cold start on screen as
|
|
5147
|
+
* a spinner. Measured 2026-08-15: the picture stopped for as long as the
|
|
5148
|
+
* first piece took. Prepared first, the player finds the bytes already there.
|
|
5149
|
+
*
|
|
5150
|
+
* The same shape as {@link prepareVariant}, and for the same reason.
|
|
5151
|
+
*
|
|
5152
|
+
* @param {string} baseSessionId
|
|
5153
|
+
* @param {number} trackIndex
|
|
5154
|
+
* @param {number} positionSeconds
|
|
5155
|
+
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
5156
|
+
*/
|
|
5157
|
+
async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds) {
|
|
5158
|
+
const base = this.sessionsById.get(baseSessionId);
|
|
5159
|
+
if (!base || base.state === "disposed" || !this.#servesAudioSeparately(base)) {
|
|
5160
|
+
return null;
|
|
5161
|
+
}
|
|
5162
|
+
if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
|
|
5163
|
+
return null;
|
|
5164
|
+
}
|
|
5165
|
+
const rendition = await this.#resolveAudioRenditionSession(base, trackIndex);
|
|
5166
|
+
if (!rendition) {
|
|
5167
|
+
return null;
|
|
5168
|
+
}
|
|
5169
|
+
// A track prepared for a change the viewer did not make would otherwise
|
|
5170
|
+
// encode for nobody until its own idle timer noticed — the same trap
|
|
5171
|
+
// warming a quality rung has, and the same answer.
|
|
5172
|
+
const stillWarming = base.warmingAudioSessionId;
|
|
5173
|
+
if (stillWarming && stillWarming !== rendition.id) {
|
|
5174
|
+
const abandoned = this.sessionsById.get(stillWarming);
|
|
5175
|
+
const listening = base.activeAudioTrackIndex ?? base.audioTrackIndex;
|
|
5176
|
+
const active = base.audioRenditionSessions?.get(listening);
|
|
5177
|
+
if (abandoned && abandoned.id !== active) {
|
|
5178
|
+
this.#stopEncodeRun(abandoned, "prepared for a track change the viewer did not make");
|
|
5179
|
+
}
|
|
5180
|
+
}
|
|
5181
|
+
base.warmingAudioSessionId = rendition.id;
|
|
5182
|
+
// Pointed at the position the switch will land on: an existing track is
|
|
5183
|
+
// parked wherever the viewer left it.
|
|
5184
|
+
this.#seekSession(rendition, positionSeconds);
|
|
5185
|
+
const index = this.#segmentIndexForTime(rendition, positionSeconds);
|
|
5186
|
+
return { sessionId: rendition.id, fileName: rendition.segmentFormat.segmentFileName(index) };
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5132
5189
|
async prepareVariant(baseSessionId, height, positionSeconds) {
|
|
5133
5190
|
if (!isSafeSessionId(baseSessionId)) {
|
|
5134
5191
|
return null;
|
|
@@ -5387,9 +5444,53 @@ export class HlsSessionManager {
|
|
|
5387
5444
|
);
|
|
5388
5445
|
return { sessionId: null, error: message };
|
|
5389
5446
|
}
|
|
5447
|
+
if (isSegment && rendition) {
|
|
5448
|
+
this.#noteAudioTrackActive(base, rendition, trackIndex);
|
|
5449
|
+
}
|
|
5390
5450
|
return { sessionId: rendition?.id ?? null };
|
|
5391
5451
|
}
|
|
5392
5452
|
|
|
5453
|
+
/**
|
|
5454
|
+
* A SEGMENT of this track is what says the viewer is listening to it — the
|
|
5455
|
+
* player fetches the playlist and the init of tracks it may never choose.
|
|
5456
|
+
*
|
|
5457
|
+
* Every other track is then stopped. Each one is an ffmpeg process AND a
|
|
5458
|
+
* reader holding pieces of the torrent in memory, and the store can only
|
|
5459
|
+
* spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
|
|
5460
|
+
* track once had three readers on one file — picture, the track they chose
|
|
5461
|
+
* and the track they left — and at a seek all three revived their windows at
|
|
5462
|
+
* once, every resident piece was pinned, a read ended with zero bytes, and
|
|
5463
|
+
* every encoder took that for the end of the file and died. Playback was over
|
|
5464
|
+
* for good; the sessions answered 500 to everything after that.
|
|
5465
|
+
*
|
|
5466
|
+
* Stopped, not disposed: the track keeps its place, its grid and its
|
|
5467
|
+
* position, so switching back does not build it again — the same treatment a
|
|
5468
|
+
* quality rung gets when the viewer moves off it.
|
|
5469
|
+
*
|
|
5470
|
+
* @param {HlsSession} base
|
|
5471
|
+
* @param {HlsSession} active
|
|
5472
|
+
* @param {number} trackIndex
|
|
5473
|
+
*/
|
|
5474
|
+
#noteAudioTrackActive(base, active, trackIndex) {
|
|
5475
|
+
if (base.activeAudioTrackIndex === trackIndex) {
|
|
5476
|
+
return;
|
|
5477
|
+
}
|
|
5478
|
+
base.activeAudioTrackIndex = trackIndex;
|
|
5479
|
+
for (const [otherIndex, sessionId] of base.audioRenditionSessions ?? []) {
|
|
5480
|
+
if (otherIndex === trackIndex) {
|
|
5481
|
+
continue;
|
|
5482
|
+
}
|
|
5483
|
+
const other = this.sessionsById.get(sessionId);
|
|
5484
|
+
if (!other || other.state === "disposed" || other.ffmpeg == null) {
|
|
5485
|
+
continue;
|
|
5486
|
+
}
|
|
5487
|
+
// Requests held on it are for segments nobody will produce now, and the
|
|
5488
|
+
// player stopped waiting for them the moment it changed track.
|
|
5489
|
+
other.waitEpoch = (other.waitEpoch ?? 0) + 1;
|
|
5490
|
+
this.#stopEncodeRun(other, `the viewer moved to audio track ${trackIndex}`);
|
|
5491
|
+
}
|
|
5492
|
+
}
|
|
5493
|
+
|
|
5393
5494
|
/**
|
|
5394
5495
|
* The session producing one audio track of this file, made on first request.
|
|
5395
5496
|
*
|
|
@@ -109,6 +109,14 @@ const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
|
109
109
|
* in. With one, a single reader would deadlock the store against itself.
|
|
110
110
|
*/
|
|
111
111
|
const MIN_RESIDENT_PIECES = 2;
|
|
112
|
+
/**
|
|
113
|
+
* How long a caller waits for a pinned piece to be released before the store
|
|
114
|
+
* calls it a deadlock. A pin lasts one read of one piece — milliseconds — so
|
|
115
|
+
* anything approaching this is a reader waiting for itself.
|
|
116
|
+
*/
|
|
117
|
+
const PINNED_WAIT_MS = 5_000;
|
|
118
|
+
/** How often a wait for a slot looks again when no event is due to wake it. */
|
|
119
|
+
const CLAIM_RETRY_MS = 50;
|
|
112
120
|
|
|
113
121
|
/**
|
|
114
122
|
* A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
|
|
@@ -151,6 +159,8 @@ export class SharedPieceStore {
|
|
|
151
159
|
* store is exhausted, when in fact it is merely mid-flight.
|
|
152
160
|
*/
|
|
153
161
|
#outstandingSlots = 0;
|
|
162
|
+
/** When the wait for a pinned piece began; 0 when nothing is waiting. */
|
|
163
|
+
#pinnedWaitStartedAt = 0;
|
|
154
164
|
/** Resolvers waiting for a slot to become claimable. @type {(() => void)[]} */
|
|
155
165
|
#waiters = [];
|
|
156
166
|
#lru;
|
|
@@ -169,7 +179,8 @@ export class SharedPieceStore {
|
|
|
169
179
|
fromDisk: 0,
|
|
170
180
|
spills: 0,
|
|
171
181
|
revivals: 0,
|
|
172
|
-
blockedByPins: 0
|
|
182
|
+
blockedByPins: 0,
|
|
183
|
+
waitedForPins: 0
|
|
173
184
|
};
|
|
174
185
|
|
|
175
186
|
/**
|
|
@@ -308,6 +319,8 @@ export class SharedPieceStore {
|
|
|
308
319
|
*/
|
|
309
320
|
unpin(index) {
|
|
310
321
|
this.#lru.unpin(index);
|
|
322
|
+
// A released pin can be exactly what a caller waiting for a slot needs.
|
|
323
|
+
this.#wake();
|
|
311
324
|
}
|
|
312
325
|
|
|
313
326
|
/**
|
|
@@ -330,6 +343,15 @@ export class SharedPieceStore {
|
|
|
330
343
|
for (const spill of this.#evicting.values()) {
|
|
331
344
|
void spill.then(() => this.#wake(), () => this.#wake());
|
|
332
345
|
}
|
|
346
|
+
// A wake is not guaranteed to come. Waiting for a spill is safe — one
|
|
347
|
+
// is in flight and will finish — but waiting for a PIN to be released
|
|
348
|
+
// is not: if every piece is held and nothing else is happening, there
|
|
349
|
+
// is no event left to fire, and the deadline that gives up cannot be
|
|
350
|
+
// reached because it is only tested inside an attempt. That is a hang,
|
|
351
|
+
// and it hung this store's own test for the full ten minutes a run is
|
|
352
|
+
// allowed. So the wait also re-checks on a timer.
|
|
353
|
+
const retry = setTimeout(() => this.#wake(), CLAIM_RETRY_MS);
|
|
354
|
+
retry.unref?.();
|
|
333
355
|
});
|
|
334
356
|
}
|
|
335
357
|
}
|
|
@@ -394,11 +416,34 @@ export class SharedPieceStore {
|
|
|
394
416
|
if (this.#evicting.size > 0 || this.#outstandingSlots > 0) {
|
|
395
417
|
return null;
|
|
396
418
|
}
|
|
397
|
-
// Every resident piece is being
|
|
398
|
-
//
|
|
419
|
+
// Every resident piece is being READ right now. That is not a permanent
|
|
420
|
+
// condition: a pin lasts as long as one read of one piece, and the reader
|
|
421
|
+
// releases it a moment later. So wait for that, exactly as the loop above
|
|
422
|
+
// waits for a spill — pins now wake the waiters.
|
|
423
|
+
//
|
|
424
|
+
// It became reachable when a viewer could have three readers on one file
|
|
425
|
+
// (2026-08-15: picture, the audio track chosen and the one left behind);
|
|
426
|
+
// failing here ended a read with zero bytes, which ffmpeg reads as the
|
|
427
|
+
// end of the file, so every encoder died and the session answered 500 to
|
|
428
|
+
// everything after that.
|
|
429
|
+
//
|
|
430
|
+
// The deadline is what keeps a genuine deadlock visible: a reader that
|
|
431
|
+
// holds a pin while waiting for a slot would otherwise wait for itself
|
|
432
|
+
// for ever.
|
|
433
|
+
if (this.#pinnedWaitStartedAt === 0) {
|
|
434
|
+
this.#pinnedWaitStartedAt = Date.now();
|
|
435
|
+
}
|
|
436
|
+
if (Date.now() - this.#pinnedWaitStartedAt < PINNED_WAIT_MS) {
|
|
437
|
+
this.#counters.waitedForPins += 1;
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
this.#pinnedWaitStartedAt = 0;
|
|
399
441
|
this.#counters.blockedByPins += 1;
|
|
400
|
-
throw new Error(
|
|
442
|
+
throw new Error(
|
|
443
|
+
`Every resident piece is pinned and none was released in ${PINNED_WAIT_MS}ms; no slot can be freed.`
|
|
444
|
+
);
|
|
401
445
|
}
|
|
446
|
+
this.#pinnedWaitStartedAt = 0;
|
|
402
447
|
|
|
403
448
|
const slot = this.#slotOf.get(victim);
|
|
404
449
|
|
|
@@ -703,3 +703,36 @@ test("a stale buffer report is not used to place an audio track", async (t) => {
|
|
|
703
703
|
"the whole look-ahead is subtracted instead — it cannot leave the run ahead of the viewer"
|
|
704
704
|
);
|
|
705
705
|
});
|
|
706
|
+
|
|
707
|
+
test("an audio track is prepared at the position the switch will land on", async (t) => {
|
|
708
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
709
|
+
t.after(async () => {
|
|
710
|
+
await manager.disposeAll();
|
|
711
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
712
|
+
});
|
|
713
|
+
base.audioSeparate = true;
|
|
714
|
+
manager.getCachedAudioTracks = () => [
|
|
715
|
+
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
716
|
+
{ index: 1, language: "eng", title: "", isDefault: false }
|
|
717
|
+
];
|
|
718
|
+
const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
|
|
719
|
+
rendition.audioOnly = true;
|
|
720
|
+
rendition.audioTrackIndex = 1;
|
|
721
|
+
rendition.ffmpeg = fakeEncoder();
|
|
722
|
+
rendition.encodeStartIndex = 0;
|
|
723
|
+
manager.sessionsById.set(VARIANT_ID, rendition);
|
|
724
|
+
base.audioRenditionSessions = new Map([[1, VARIANT_ID]]);
|
|
725
|
+
|
|
726
|
+
const prepared = await manager.prepareAudioTrack(BASE_ID, 1, 240);
|
|
727
|
+
|
|
728
|
+
assert.deepEqual(
|
|
729
|
+
prepared,
|
|
730
|
+
{ sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
|
|
731
|
+
"the caller is told which segment to wait for — 240 s on a four-second grid"
|
|
732
|
+
);
|
|
733
|
+
assert.equal(
|
|
734
|
+
rendition.seekTarget,
|
|
735
|
+
59,
|
|
736
|
+
"and the track is pointed at the switch position, one back for the preceding keyframe"
|
|
737
|
+
);
|
|
738
|
+
});
|