Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion annotation-tool/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fovea/annotation-tool",
"private": true,
"version": "0.5.1",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
45 changes: 44 additions & 1 deletion annotation-tool/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions annotation-tool/src/components/annotation/AnnotationWorkspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
20 changes: 20 additions & 0 deletions docs/docs/project/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "docs",
"version": "0.5.1",
"version": "0.5.2",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
Expand Down
2 changes: 1 addition & 1 deletion model-service/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion model-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fovea/server",
"version": "0.5.1",
"version": "0.5.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
19 changes: 19 additions & 0 deletions server/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion server/src/routes/videos/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading