From 9d71b34a469093ba8232858a0d197496bf6238d7 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 24 Jun 2026 08:17:08 -0400 Subject: [PATCH] release: promote release/0.5.x to main for v0.5.2 --- CHANGELOG.md | 20 +++ annotation-tool/package.json | 2 +- annotation-tool/playwright.config.ts | 45 ++++++- .../annotation/AnnotationWorkspace.css | 14 +++ .../cross-browser/video-pause-resume.spec.ts | 116 ++++++++++++++++++ docs/docs/project/changelog.md | 20 +++ docs/package.json | 2 +- model-service/package.json | 2 +- model-service/pyproject.toml | 2 +- package.json | 2 +- server/package.json | 2 +- server/src/lib/errors.ts | 19 +++ server/src/routes/videos/stream.ts | 13 +- server/src/services/videoStorage.ts | 79 +++++++++++- server/test/services/videoStorage.test.ts | 100 +++++++++++++++ 15 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 annotation-tool/test/e2e/regression/cross-browser/video-pause-resume.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d31026..1d1da99e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to the Fovea project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.2] - 2026-06-22 + +The 0.5.2 patch fixes a Safari-only failure ([#143](https://github.com/parafovea/fovea/issues/143)) where a video in the annotation workspace blacked out the moment it was paused and jumped position on resume, while Chrome and Firefox played it back correctly. The cause was twofold: the video stream endpoint mishandled the byte-range requests Safari issues but Chrome does not, and WebKit dropped the paused video frame from its compositor. No API shapes change and nothing is breaking. + +### Fixed + +#### Video Stream Range Requests Handle Safari's Suffix and Edge Ranges + +- The local video stream provider (`server/src/services/videoStorage.ts`) parsed the HTTP `Range` header with a naive `split('-')`. A suffix range (`bytes=-N`, which Safari uses to read a file's trailing `moov` atom and to re-buffer on pause and resume) produced a `NaN` start offset and threw, and a range whose end ran past the file declared a `Content-Length` larger than the bytes actually streamed. Chrome and Firefox issue plain bounded ranges and were unaffected, so the player worked there while Safari received a failed request mid-playback, blacked out, and re-seeked on resume. Range parsing now follows RFC 7233: suffix ranges resolve to the last N bytes, open-ended ranges run to the last byte, an end past the file clamps so `Content-Length` always matches the stream, and a start past the file returns `416 Range Not Satisfiable` with a `Content-Range` header rather than a 404 that strict clients treat as a fatal media error. + +#### Paused Video Keeps Its Frame in Safari + +- The annotation video element (`annotation-tool/src/components/annotation/AnnotationWorkspace.css`) is composited beneath the interactive annotation overlay, and WebKit stopped repainting the last decoded frame the instant playback paused, showing the container's black background instead. The element is now pinned to its own GPU compositing layer (`transform: translateZ(0)` with `backface-visibility: hidden`), which keeps the frame painted while paused. + +### Added + +#### Cross-Browser Video Playback E2E Coverage + +- The Playwright matrix gained `video-chromium`, `video-webkit`, and `video-firefox` projects (`annotation-tool/playwright.config.ts`) that run a new pause-and-resume spec under all three engines, since the regression above was WebKit-only and the prior E2E matrix ran only under Chrome. The spec asserts that the stream endpoint answers Safari's suffix and edge byte ranges, that the playhead stays steady across pause and resume, and that the paused frame stays decoded. + ## [0.5.1] - 2026-06-22 The 0.5.1 patch resolves a batch of field-reported bugs surfaced on a self-hosted production deployment, spanning backend request validation and rate limiting, frontend request fan-out and resilience, and a set of annotation-workspace and persona-builder display fixes. No API shapes change and nothing is breaking; the cross-service contracts are unchanged. diff --git a/annotation-tool/package.json b/annotation-tool/package.json index 6e5750d1..93162c5a 100644 --- a/annotation-tool/package.json +++ b/annotation-tool/package.json @@ -1,7 +1,7 @@ { "name": "@fovea/annotation-tool", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/annotation-tool/playwright.config.ts b/annotation-tool/playwright.config.ts index a2fa0251..80b773fa 100644 --- a/annotation-tool/playwright.config.ts +++ b/annotation-tool/playwright.config.ts @@ -74,7 +74,9 @@ export default defineConfig({ { name: 'regression', testDir: './test/e2e/regression', - testIgnore: '**/visual/**', // Visual tests run only in visual project + // Visual tests run only in the visual project; cross-browser specs run + // once per engine in the dedicated video-* projects, not here. + testIgnore: ['**/visual/**', '**/cross-browser/**'], timeout: 60000, retries: 1, workers: 2, // matches the number of test webm fixtures so each worker isolates onto its own video row @@ -84,6 +86,47 @@ export default defineConfig({ } }, + // Cross-browser video playback coverage. The annotation video player has + // had WebKit-only regressions (paused frames blacking out, stream + // range-request crashes) that a Chrome-only matrix cannot catch, so these + // specs run under all three engines. They live in their own directory and + // are excluded from the regression project above so each runs once per + // engine rather than twice under Chrome. One worker per engine keeps the + // single shared video row free of cross-test contention. + { + name: 'video-chromium', + testDir: './test/e2e/regression/cross-browser', + timeout: 60000, + retries: 1, + workers: 1, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 720 } + } + }, + { + name: 'video-webkit', + testDir: './test/e2e/regression/cross-browser', + timeout: 60000, + retries: 1, + workers: 1, + use: { + ...devices['Desktop Safari'], + viewport: { width: 1280, height: 720 } + } + }, + { + name: 'video-firefox', + testDir: './test/e2e/regression/cross-browser', + timeout: 60000, + retries: 1, + workers: 1, + use: { + ...devices['Desktop Firefox'], + viewport: { width: 1280, height: 720 } + } + }, + // Accessibility tests - WCAG 2.1 AA compliance validation // Tests keyboard navigation, screen reader compatibility, and ARIA attributes // Uses axe-core for automated accessibility auditing diff --git a/annotation-tool/src/components/annotation/AnnotationWorkspace.css b/annotation-tool/src/components/annotation/AnnotationWorkspace.css index 13cfc610..c36f10a9 100644 --- a/annotation-tool/src/components/annotation/AnnotationWorkspace.css +++ b/annotation-tool/src/components/annotation/AnnotationWorkspace.css @@ -53,4 +53,18 @@ height: 100% !important; object-fit: contain !important; z-index: 0 !important; + /* + * Pin the video onto its own GPU compositing layer. WebKit composites the + * absolutely-positioned video underneath the interactive annotation SVG + * overlay; without a dedicated layer it stops repainting the last decoded + * frame the moment playback pauses and shows the container's black + * background instead (a Safari-only black-out on pause). Promoting the + * element keeps the frame painted while paused. translateZ(0) forces the + * layer; backface-visibility paired with it is the long-standing Safari + * incantation that keeps the promotion stable across repaints. + */ + transform: translateZ(0); + -webkit-transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; } \ No newline at end of file diff --git a/annotation-tool/test/e2e/regression/cross-browser/video-pause-resume.spec.ts b/annotation-tool/test/e2e/regression/cross-browser/video-pause-resume.spec.ts new file mode 100644 index 00000000..b153600c --- /dev/null +++ b/annotation-tool/test/e2e/regression/cross-browser/video-pause-resume.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '../../fixtures/test-context.js' + +/** + * Cross-browser regression coverage for video playback when paused and + * resumed in the annotation workspace. These specs run under chromium, + * webkit, and firefox (see the `video-*` projects in playwright.config.ts) + * because the failure they guard against was WebKit-only: paused videos + * blacked out and jumped position on resume while Chrome played fine. + * + * Two root causes are covered: + * + * 1. The stream endpoint crashed on the suffix / open-ended `Range` requests + * WebKit issues (Chrome rarely does), so the media element received an + * error mid-playback and blacked out. The range specs assert the endpoint + * answers those requests correctly across every engine. + * 2. The paused frame must stay decoded and the playhead must not jump, which + * the interaction spec verifies directly. + */ +test.describe('Video pause and resume', () => { + test('serves the suffix and edge byte ranges WebKit requests', async ({ + annotationWorkspace, + testUser, + testPersona, + testVideo, + }) => { + await annotationWorkspace.navigateFromVideoBrowser() + + const streamUrl = `/api/videos/${testVideo.id}/stream` + + // Suffix range: the last N bytes (WebKit uses this to read the trailing + // moov atom). The old parser produced NaN bounds and 404'd the request. + const suffix = await annotationWorkspace.page.request.get(streamUrl, { + headers: { Range: 'bytes=-1024' }, + }) + expect(suffix.status()).toBe(206) + const suffixBody = await suffix.body() + expect(suffixBody.length).toBe(1024) + const suffixRange = suffix.headers()['content-range'] + expect(suffixRange).toMatch(/^bytes \d+-\d+\/\d+$/) + const [, end, total] = suffixRange.match(/^bytes \d+-(\d+)\/(\d+)$/)! + expect(Number(end)).toBe(Number(total) - 1) + + // A plain bounded probe range stays correct. + const probe = await annotationWorkspace.page.request.get(streamUrl, { + headers: { Range: 'bytes=0-1' }, + }) + expect(probe.status()).toBe(206) + expect((await probe.body()).length).toBe(2) + + // A range that starts past EOF is unsatisfiable: 416 (not a 404 that + // strict clients treat as a fatal media error). + const past = await annotationWorkspace.page.request.get(streamUrl, { + headers: { Range: 'bytes=999999999-' }, + }) + expect(past.status()).toBe(416) + expect(past.headers()['content-range']).toMatch(/^bytes \*\/\d+$/) + }) + + test('keeps the frame decoded and the playhead steady across pause/resume', async ({ + annotationWorkspace, + page, + testUser, + testPersona, + testVideo, + }) => { + await annotationWorkspace.navigateFromVideoBrowser() + const video = annotationWorkspace.video + + // Play and let real playback advance so there is a decoded frame and a + // non-zero playhead to freeze. + await video.play() + await video.waitForPlaying() + await expect + .poll(async () => video.getCurrentTime(), { timeout: 5000 }) + .toBeGreaterThan(0.1) + const timeAtPause = await video.getCurrentTime() + + await video.pause() + await expect(video.videoElement).toHaveJSProperty('paused', true) + + // The playhead must not jump on pause. + const timeAfterPause = await video.getCurrentTime() + expect(Math.abs(timeAfterPause - timeAtPause)).toBeLessThan(0.5) + + // The paused frame must still be decoded. drawImage reads the decoder + // surface, so a non-black sample proves the source did not error out and + // tear down the frame (the WebKit black-out failure mode). The video is + // served same-origin through the app proxy, so the canvas is not tainted. + const frame = await video.videoElement.evaluate((v: HTMLVideoElement) => { + const canvas = document.createElement('canvas') + canvas.width = v.videoWidth + canvas.height = v.videoHeight + const ctx = canvas.getContext('2d') + if (!ctx || !v.videoWidth) return { ok: false, brightness: -1 } + ctx.drawImage(v, 0, 0, canvas.width, canvas.height) + const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height) + let sum = 0 + // Sample a sparse grid of pixels; the test fixture footage is not black. + for (let i = 0; i < data.length; i += 4 * 997) { + sum += data[i] + data[i + 1] + data[i + 2] + } + return { ok: true, brightness: sum } + }) + expect(frame.ok).toBe(true) + expect(frame.brightness).toBeGreaterThan(0) + + // Resume continues from where it paused rather than seeking seconds away. + await video.play() + await video.waitForPlaying() + const timeOnResume = await video.getCurrentTime() + expect(timeOnResume).toBeGreaterThanOrEqual(timeAfterPause - 0.5) + expect(timeOnResume).toBeLessThan(timeAfterPause + 1.0) + + await page.waitForTimeout(200) + }) +}) diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index c0522b38..6007b5a4 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -11,6 +11,26 @@ All notable changes to the Fovea project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.2] - 2026-06-22 + +The 0.5.2 patch fixes a Safari-only failure ([#143](https://github.com/parafovea/fovea/issues/143)) where a video in the annotation workspace blacked out the moment it was paused and jumped position on resume, while Chrome and Firefox played it back correctly. The cause was twofold: the video stream endpoint mishandled the byte-range requests Safari issues but Chrome does not, and WebKit dropped the paused video frame from its compositor. No API shapes change and nothing is breaking. + +### Fixed + +#### Video Stream Range Requests Handle Safari's Suffix and Edge Ranges + +- The local video stream provider (`server/src/services/videoStorage.ts`) parsed the HTTP `Range` header with a naive `split('-')`. A suffix range (`bytes=-N`, which Safari uses to read a file's trailing `moov` atom and to re-buffer on pause and resume) produced a `NaN` start offset and threw, and a range whose end ran past the file declared a `Content-Length` larger than the bytes actually streamed. Chrome and Firefox issue plain bounded ranges and were unaffected, so the player worked there while Safari received a failed request mid-playback, blacked out, and re-seeked on resume. Range parsing now follows RFC 7233: suffix ranges resolve to the last N bytes, open-ended ranges run to the last byte, an end past the file clamps so `Content-Length` always matches the stream, and a start past the file returns `416 Range Not Satisfiable` with a `Content-Range` header rather than a 404 that strict clients treat as a fatal media error. + +#### Paused Video Keeps Its Frame in Safari + +- The annotation video element (`annotation-tool/src/components/annotation/AnnotationWorkspace.css`) is composited beneath the interactive annotation overlay, and WebKit stopped repainting the last decoded frame the instant playback paused, showing the container's black background instead. The element is now pinned to its own GPU compositing layer (`transform: translateZ(0)` with `backface-visibility: hidden`), which keeps the frame painted while paused. + +### Added + +#### Cross-Browser Video Playback E2E Coverage + +- The Playwright matrix gained `video-chromium`, `video-webkit`, and `video-firefox` projects (`annotation-tool/playwright.config.ts`) that run a new pause-and-resume spec under all three engines, since the regression above was WebKit-only and the prior E2E matrix ran only under Chrome. The spec asserts that the stream endpoint answers Safari's suffix and edge byte ranges, that the playhead stays steady across pause and resume, and that the paused frame stays decoded. + ## [0.5.1] - 2026-06-22 The 0.5.1 patch resolves a batch of field-reported bugs surfaced on a self-hosted production deployment, spanning backend request validation and rate limiting, frontend request fan-out and resilience, and a set of annotation-workspace and persona-builder display fixes. No API shapes change and nothing is breaking; the cross-service contracts are unchanged. diff --git a/docs/package.json b/docs/package.json index a7ac2944..dd6260f9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "0.5.1", + "version": "0.5.2", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/model-service/package.json b/model-service/package.json index fd4dbba7..7708bd06 100644 --- a/model-service/package.json +++ b/model-service/package.json @@ -1,6 +1,6 @@ { "name": "fovea-model-service", - "version": "0.5.1", + "version": "0.5.2", "description": "AI model inference service for video annotation", "scripts": { "dev": "source venv/bin/activate && uvicorn src.main:app --reload --host 0.0.0.0 --port 8000", diff --git a/model-service/pyproject.toml b/model-service/pyproject.toml index fdee1219..1f2bcc53 100644 --- a/model-service/pyproject.toml +++ b/model-service/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fovea-model-service" -version = "0.5.1" +version = "0.5.2" description = "Model service for fovea video annotation tool" requires-python = ">=3.12" dependencies = [ diff --git a/package.json b/package.json index 0ac5c051..d5412261 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fovea", "private": true, - "version": "0.5.1", + "version": "0.5.2", "description": "FOVEA - Flexible Ontology Visual Event Analyzer", "packageManager": "pnpm@10.15.0", "engines": { diff --git a/server/package.json b/server/package.json index 4cfe6c6d..70a854ac 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fovea/server", - "version": "0.5.1", + "version": "0.5.2", "private": true, "type": "module", "scripts": { diff --git a/server/src/lib/errors.ts b/server/src/lib/errors.ts index 4754f65c..2a7f79bb 100644 --- a/server/src/lib/errors.ts +++ b/server/src/lib/errors.ts @@ -154,6 +154,25 @@ export class ConflictError extends AppError { } } +/** + * 416 Range Not Satisfiable error. + * Use when a syntactically valid byte-range request cannot be served against + * the resource (e.g. the requested start is past the end of the file). The + * caller is expected to respond with a `Content-Range` header reporting the + * total resource size so the client can re-request a satisfiable range. + * + * @example + * ```typescript + * throw new RangeNotSatisfiableError(fileSize) + * // Returns: 416 { error: 'RANGE_NOT_SATISFIABLE', message: 'Requested range not satisfiable' } + * ``` + */ +export class RangeNotSatisfiableError extends AppError { + constructor(public readonly size: number) { + super(416, 'RANGE_NOT_SATISFIABLE', 'Requested range not satisfiable', { size }) + } +} + /** * 429 Too Many Requests error. * Use when rate limiting or lockout is triggered. diff --git a/server/src/routes/videos/stream.ts b/server/src/routes/videos/stream.ts index 92aefff2..0a96e47f 100644 --- a/server/src/routes/videos/stream.ts +++ b/server/src/routes/videos/stream.ts @@ -2,7 +2,7 @@ import { FastifyPluginAsync } from 'fastify' import { Type } from '@sinclair/typebox' import { VideoRepository } from '../../repositories/VideoRepository.js' import { VideoStorageProvider } from '../../services/videoStorage.js' -import { NotFoundError, InternalError } from '../../lib/errors.js' +import { NotFoundError, InternalError, RangeNotSatisfiableError } from '../../lib/errors.js' /** * Video streaming route with range request support. @@ -74,6 +74,17 @@ export const streamRoutes: FastifyPluginAsync<{ .header('Accept-Ranges', 'bytes') .send(result.stream) } catch (error) { + // A well-formed but unsatisfiable range (start past EOF) gets a 416 + // with the resource size so the client can re-request, per RFC 7233. + // This must not be swallowed into the 404 below, or strict clients + // (Safari) abort playback on the unexpected status and black out. + if (error instanceof RangeNotSatisfiableError) { + return reply + .code(416) + .header('Content-Range', `bytes */${error.size}`) + .header('Accept-Ranges', 'bytes') + .send() + } fastify.log.error({ error, videoId }, 'Failed to stream video') throw new NotFoundError('Video', videoId) } diff --git a/server/src/services/videoStorage.ts b/server/src/services/videoStorage.ts index c42d0847..d54eafd2 100644 --- a/server/src/services/videoStorage.ts +++ b/server/src/services/videoStorage.ts @@ -14,6 +14,73 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { readFile } from 'fs/promises'; import { config as appConfig } from '../config.js'; +import { RangeNotSatisfiableError } from '../lib/errors.js'; + +/** + * Resolved inclusive byte range (both bounds are byte offsets the client + * will receive, `end` included). + */ +export interface ResolvedByteRange { + start: number; + end: number; +} + +/** + * Parse a single HTTP `Range` request header against a known resource size, + * following RFC 7233 §2.1/§3.1. Handles the three forms media clients send: + * + * - `bytes=START-END` fully bounded range + * - `bytes=START-` open-ended range (to the last byte) + * - `bytes=-SUFFIX` suffix range (the last SUFFIX bytes) + * + * Safari issues suffix and open-ended ranges (e.g. to read the trailing + * `moov` atom and to re-buffer on pause/resume) far more readily than Chrome, + * so mishandling them surfaces as Safari-only black frames and seek jumps + * while Chrome plays fine. + * + * @param rangeHeader - The raw `Range` header value, or undefined. + * @param size - The total size of the resource in bytes. + * @returns the resolved range when satisfiable; `'unsatisfiable'` when the + * range is well-formed but cannot be served (caller responds 416); or + * `null` when no/malformed/multi-range header is present (caller serves the + * full resource, since RFC 7233 says an unparseable Range is ignored). + */ +export function parseByteRange( + rangeHeader: string | undefined, + size: number +): ResolvedByteRange | 'unsatisfiable' | null { + if (!rangeHeader) return null; + + // Only a single `bytes=` range is supported; anything else (multi-range, + // non-bytes units, junk) is ignored so the full resource is served. + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match) return null; + + const [, startStr, endStr] = match; + // `bytes=-` carries neither bound and is malformed. + if (startStr === '' && endStr === '') return null; + + // A zero-length resource cannot satisfy any positive range request. + if (size <= 0) return 'unsatisfiable'; + + let start: number; + let end: number; + if (startStr === '') { + // Suffix range: the last `suffixLength` bytes. + const suffixLength = parseInt(endStr, 10); + if (suffixLength <= 0) return 'unsatisfiable'; + start = Math.max(0, size - suffixLength); + end = size - 1; + } else { + start = parseInt(startStr, 10); + // Open-ended ranges run to the last byte; a stated end past EOF clamps to + // it so the declared Content-Length always matches the bytes streamed. + end = endStr === '' ? size - 1 : Math.min(parseInt(endStr, 10), size - 1); + } + + if (start > end || start >= size) return 'unsatisfiable'; + return { start, end }; +} /** * Video Storage Provider Interface @@ -228,10 +295,12 @@ class LocalStorageProvider implements VideoStorageProvider { const contentType = this.getContentType(fullPath); // Handle range requests - if (range) { - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + const parsed = parseByteRange(range, stats.size); + if (parsed === 'unsatisfiable') { + throw new RangeNotSatisfiableError(stats.size); + } + if (parsed) { + const { start, end } = parsed; const chunkSize = end - start + 1; const stream = createReadStream(fullPath, { start, end }); @@ -248,7 +317,7 @@ class LocalStorageProvider implements VideoStorageProvider { }; } - // Full file + // Full file (no range header, or one that could not be parsed) const stream = createReadStream(fullPath); return { stream, diff --git a/server/test/services/videoStorage.test.ts b/server/test/services/videoStorage.test.ts index 39e440d4..73adbb84 100644 --- a/server/test/services/videoStorage.test.ts +++ b/server/test/services/videoStorage.test.ts @@ -5,9 +5,11 @@ import os from 'os' import { createVideoStorageProvider, loadStorageConfig, + parseByteRange, VideoStorageProvider, VideoStorageConfig, } from '../../src/services/videoStorage.js' +import { RangeNotSatisfiableError } from '../../src/lib/errors.js' describe('Video Storage Providers', () => { let tempDir: string @@ -61,6 +63,58 @@ describe('Video Storage Providers', () => { }) }) + // Reads a Readable fully into a Buffer so range tests can assert the + // exact bytes streamed, not just the declared content length. + const collect = async (stream: import('stream').Readable): Promise => { + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks) + } + + it('serves a suffix range (the last N bytes Safari requests)', async () => { + const result = await provider.getVideoStream('test-video.mp4', 'bytes=-5') + + expect(result.contentLength).toBe(5) + expect(result.range).toEqual({ start: 13, end: 17, total: 18 }) + expect((await collect(result.stream)).toString()).toBe('ntent') + }) + + it('serves an open-ended range to the last byte', async () => { + const result = await provider.getVideoStream('test-video.mp4', 'bytes=5-') + + expect(result.contentLength).toBe(13) + expect(result.range).toEqual({ start: 5, end: 17, total: 18 }) + expect((await collect(result.stream)).toString()).toBe('video content') + }) + + it('clamps an end past EOF so Content-Length matches the bytes streamed', async () => { + const result = await provider.getVideoStream('test-video.mp4', 'bytes=10-100') + + expect(result.range).toEqual({ start: 10, end: 17, total: 18 }) + expect(result.contentLength).toBe(8) + expect((await collect(result.stream)).length).toBe(8) + }) + + it('treats an oversized suffix as the whole file', async () => { + const result = await provider.getVideoStream('test-video.mp4', 'bytes=-100') + + expect(result.range).toEqual({ start: 0, end: 17, total: 18 }) + expect(result.contentLength).toBe(18) + }) + + it('rejects a range starting past EOF with RangeNotSatisfiableError', async () => { + await expect( + provider.getVideoStream('test-video.mp4', 'bytes=100-200') + ).rejects.toBeInstanceOf(RangeNotSatisfiableError) + }) + + it('ignores a malformed range header and serves the full file', async () => { + const result = await provider.getVideoStream('test-video.mp4', 'bytes=abc') + + expect(result.range).toBeUndefined() + expect(result.contentLength).toBe(18) + }) + it('should get video URL for local storage', async () => { const url = await provider.getVideoUrl('test-video.mp4') @@ -268,3 +322,49 @@ describe('Video Storage Providers', () => { }) }) }) + +describe('parseByteRange', () => { + const SIZE = 1000 + + it('returns null when no range header is present', () => { + expect(parseByteRange(undefined, SIZE)).toBeNull() + }) + + it('resolves a fully bounded range', () => { + expect(parseByteRange('bytes=100-200', SIZE)).toEqual({ start: 100, end: 200 }) + }) + + it('resolves an open-ended range to the last byte', () => { + expect(parseByteRange('bytes=0-', SIZE)).toEqual({ start: 0, end: 999 }) + expect(parseByteRange('bytes=500-', SIZE)).toEqual({ start: 500, end: 999 }) + }) + + it('resolves a suffix range to the last N bytes', () => { + expect(parseByteRange('bytes=-500', SIZE)).toEqual({ start: 500, end: 999 }) + }) + + it('clamps a suffix larger than the file to the whole file', () => { + expect(parseByteRange('bytes=-5000', SIZE)).toEqual({ start: 0, end: 999 }) + }) + + it('clamps an end past EOF to the last byte', () => { + expect(parseByteRange('bytes=999-5000', SIZE)).toEqual({ start: 999, end: 999 }) + }) + + it('reports a start past EOF as unsatisfiable', () => { + expect(parseByteRange('bytes=1000-2000', SIZE)).toBe('unsatisfiable') + expect(parseByteRange('bytes=2000-', SIZE)).toBe('unsatisfiable') + }) + + it('reports a zero-length suffix and zero-size resource as unsatisfiable', () => { + expect(parseByteRange('bytes=-0', SIZE)).toBe('unsatisfiable') + expect(parseByteRange('bytes=0-100', 0)).toBe('unsatisfiable') + }) + + it('ignores malformed, multi-range, and non-bytes headers', () => { + expect(parseByteRange('bytes=abc', SIZE)).toBeNull() + expect(parseByteRange('bytes=-', SIZE)).toBeNull() + expect(parseByteRange('bytes=0-100,200-300', SIZE)).toBeNull() + expect(parseByteRange('items=0-100', SIZE)).toBeNull() + }) +})