diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,39 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Browser tests + run: npm run test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index f3869fc..c47a100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Added bounded flat-profile TTML import/export, timeline cue insertion, + duplication, split/merge/delete, text transforms and frame snapping. +- Added immediate media playback plus cancellable background waveform extraction, + click/keyboard waveform navigation and stale-result suppression. +- Declared accurate subtitle/media input, export and optional browser capability + profiles for Toolbox handoff. + ## 0.1.0 - 2026-09-01 - Added bounded SRT, WebVTT and ASS parsing, cue editing and explicit conversion. diff --git a/README.md b/README.md index 9166f5f..720151f 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,30 @@ Edit, validate, synchronize, compare, and convert subtitle files locally in the browser. Subtitle Tools is an independently versioned application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). -## Version 0.1 scope +## Version 0.2 scope -- Bounded SRT, WebVTT, ASS and legacy SSA-compatible dialogue parsing -- Cue timeline, direct text/timestamp editing and configurable diagnostics for +- Bounded SRT, WebVTT, ASS/SSA and flat-profile TTML/DFXP parsing +- Cue timeline, add/duplicate/split/merge/delete operations, direct + text/timestamp editing, cue text transforms and configurable diagnostics for overlaps, order, duration, reading speed, line count and line length -- Shift and anchor-based stretch operations, negative-time protection and - explicit source/target frame-rate conversion -- SRT, WebVTT and ASS serialization with format-specific preservation where +- Shift and anchor-based stretch operations, negative-time protection, + source/target frame-rate conversion and frame-grid snapping +- SRT, WebVTT, ASS and safe flat TTML serialization with format-specific preservation where possible and a visible cross-format loss warning - Deterministic index-aligned revision comparison -- Immediate browser-native audio/video playback plus bounded local waveform peak - extraction and subtitle-start markers +- Immediate browser-native audio/video playback while cancellable, worker-based + waveform peak extraction continues; failed or stale jobs retain the last + valid waveform; the scrollable waveform provides zoom, cue selection, + previous/next navigation and a live playhead - UTF-8, UTF-16 LE/BE and ISO-8859-1 file decoding All source, media and generated output stays in the current browser. The app makes no external request. Validation is configurable editorial guidance, not a -broadcast, accessibility, or legal compliance certificate. Version 0.1 does not +broadcast, accessibility, or legal compliance certificate. Version 0.2 does not perform speech recognition, automatic alignment, semantic revision matching, or -pixel-perfect ASS rendering. +pixel-perfect ASS rendering. TTML regions, styling definitions and inherited +timing are not laid out; cue-level references are inspected and export is a +flat, resource-free profile. ## Development @@ -37,7 +42,7 @@ npm run test:browser ## Release `npm run release:artifact` creates the deterministic -`release/subtitle-tools-0.1.0.zip` and checksum sidecar. +`release/subtitle-tools-0.2.0.zip` and checksum sidecar. ## Licence diff --git a/SOURCE.md b/SOURCE.md index e8f0025..9d4cfa3 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,7 +1,7 @@ # Corresponding source -The corresponding source for Subtitle Tools 0.1.0 is available at: +The corresponding source for Subtitle Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/subtitle-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/subtitle-tools/src/tag/v0.2.0 Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 76b5f1f..9179d8f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,12 +1,12 @@ # Third-party notices -Subtitle Tools 0.1.0 directly depends on these runtime packages: +Subtitle Tools 0.2.0 directly depends on these runtime packages: | Package | Pinned version | Declared licence | | -------------------------------- | -------------: | ---------------- | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | -| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | +| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | | `react` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT | diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 29b8621..d321e60 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -1,6 +1,6 @@ # Accessibility -The application uses labelled native controls, keyboard-reachable tabs and cue +The application uses labelled native controls, keyboard-reachable pressed-state workspace buttons and cue buttons, visible focus indicators, text alternatives for waveform content, and light, dark and system themes. Colour is not the only carrier for issue level. The cue and issue views cap rendered rows to keep navigation responsive while diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72b162b..f23a00c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,16 +2,24 @@ Subtitle Tools is a relocatable React application with a framework-independent TypeScript subtitle model. The parser first applies byte/text and line bounds, -then produces one normalized cue model for SRT, WebVTT and ASS. Operations return -new documents instead of mutating parser state. Serializers are format-specific; -cross-format export deliberately exposes its preservation boundary. +then produces one normalized cue model for SRT, WebVTT, ASS and a bounded flat +TTML profile. XML document types/entities are rejected; TTML resources, +inherited timing and layout are never resolved. Cue structure/text/timing +operations return new documents instead of mutating parser state. Serializers +are format-specific; cross-format export deliberately exposes its preservation +boundary. -Parsing and timing transformations are synchronous in v0.1 and therefore capped +Parsing and timing transformations are synchronous in v0.2 and therefore capped at 4,000,000 UTF-16 units, 250,000 units per physical line and 100,000 cues. The editor renders at most 10,000 cue rows and 2,000 issue rows, while validation and export still cover the complete bounded document. Revision comparison is index-aligned and linear rather than an unbounded semantic diff. -The optional waveform component uses browser-native media decoding, keeps at -most 2,000 peak buckets and accepts no more than 128 MiB or four decoded hours. -It does not run a codec, speech-recognition model or network service. +The optional waveform component creates a browser-native playback URL as soon as +media is selected. Decoding continues independently and transfers one PCM +channel to a disposable module worker, which produces at most 2,000 peak +buckets. Replacement and cancellation terminate the worker, suppress stale +results and retain the last valid waveform. Zoom/navigation changes only the +bounded peak view and native playback position. Inputs remain capped at 128 MiB +and four decoded hours; no codec, speech-recognition model or network service +runs. diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md index 9bc942f..86dbb3b 100644 --- a/docs/PRIVACY-SECURITY.md +++ b/docs/PRIVACY-SECURITY.md @@ -6,9 +6,12 @@ other network destination exists. The production policy uses `connect-src 'self'`; browser tests fail on non-local requests. Subtitle text is rendered by React as text, not interpreted as HTML. ASS override -codes and WebVTT tags are preserved as inert source. Exports are downloads chosen -by the user. Browser-native media playback and Web Audio decoding inherit browser -codec support and may allocate substantial memory inside the documented bounds. +codes and WebVTT tags are preserved as inert source. TTML import rejects document +types and entity declarations and reads a deliberately flat, bounded profile; it +does not fetch referenced styles, images, fonts or other resources. Exports are +downloads chosen by the user. Browser-native media playback and Web Audio +decoding inherit browser codec support and may allocate substantial memory inside +the documented bounds. Validation findings are editorial signals. They are not proof of accessibility, broadcast conformance, synchronization quality or legal compliance. Cross-format diff --git a/package-lock.json b/package-lock.json index 81d7ca4..bb94557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,22 @@ { "name": "subtitle-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subtitle-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -42,24 +42,24 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "license": "Apache-2.0", - "engines": { - "node": ">=20" - } + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", + "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-helpers": { - "version": "0.1.0", - "license": "GPL-3.0-or-later", - "engines": { - "node": ">=22" - } + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz", + "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==", + "license": "GPL-3.0-or-later" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -67,17 +67,16 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" - }, - "engines": { - "node": ">=20" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index 7d18a8b..60b034e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subtitle-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Edit, validate, synchronize, compare, and convert subtitles locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -39,14 +39,14 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 357f950..9d58c7a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index f3869fc..c47a100 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Added bounded flat-profile TTML import/export, timeline cue insertion, + duplication, split/merge/delete, text transforms and frame snapping. +- Added immediate media playback plus cancellable background waveform extraction, + click/keyboard waveform navigation and stale-result suppression. +- Declared accurate subtitle/media input, export and optional browser capability + profiles for Toolbox handoff. + ## 0.1.0 - 2026-09-01 - Added bounded SRT, WebVTT and ASS parsing, cue editing and explicit conversion. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 45a7c95..4f412f1 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -198,7 +198,7 @@ Declared licence: Apache-2.0 ============================================================================== -@add-ideas/toolbox-helpers@0.1.0 +@add-ideas/toolbox-helpers@0.2.0 Declared licence: GPL-3.0-or-later ============================================================================== --- LICENSE --- @@ -879,7 +879,7 @@ Public License instead of this License. But first, please read ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- diff --git a/public/README.md b/public/README.md index 9166f5f..720151f 100644 --- a/public/README.md +++ b/public/README.md @@ -4,25 +4,30 @@ Edit, validate, synchronize, compare, and convert subtitle files locally in the browser. Subtitle Tools is an independently versioned application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). -## Version 0.1 scope +## Version 0.2 scope -- Bounded SRT, WebVTT, ASS and legacy SSA-compatible dialogue parsing -- Cue timeline, direct text/timestamp editing and configurable diagnostics for +- Bounded SRT, WebVTT, ASS/SSA and flat-profile TTML/DFXP parsing +- Cue timeline, add/duplicate/split/merge/delete operations, direct + text/timestamp editing, cue text transforms and configurable diagnostics for overlaps, order, duration, reading speed, line count and line length -- Shift and anchor-based stretch operations, negative-time protection and - explicit source/target frame-rate conversion -- SRT, WebVTT and ASS serialization with format-specific preservation where +- Shift and anchor-based stretch operations, negative-time protection, + source/target frame-rate conversion and frame-grid snapping +- SRT, WebVTT, ASS and safe flat TTML serialization with format-specific preservation where possible and a visible cross-format loss warning - Deterministic index-aligned revision comparison -- Immediate browser-native audio/video playback plus bounded local waveform peak - extraction and subtitle-start markers +- Immediate browser-native audio/video playback while cancellable, worker-based + waveform peak extraction continues; failed or stale jobs retain the last + valid waveform; the scrollable waveform provides zoom, cue selection, + previous/next navigation and a live playhead - UTF-8, UTF-16 LE/BE and ISO-8859-1 file decoding All source, media and generated output stays in the current browser. The app makes no external request. Validation is configurable editorial guidance, not a -broadcast, accessibility, or legal compliance certificate. Version 0.1 does not +broadcast, accessibility, or legal compliance certificate. Version 0.2 does not perform speech recognition, automatic alignment, semantic revision matching, or -pixel-perfect ASS rendering. +pixel-perfect ASS rendering. TTML regions, styling definitions and inherited +timing are not laid out; cue-level references are inspected and export is a +flat, resource-free profile. ## Development @@ -37,7 +42,7 @@ npm run test:browser ## Release `npm run release:artifact` creates the deterministic -`release/subtitle-tools-0.1.0.zip` and checksum sidecar. +`release/subtitle-tools-0.2.0.zip` and checksum sidecar. ## Licence diff --git a/public/SOURCE.md b/public/SOURCE.md index e8f0025..9d4cfa3 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,7 +1,7 @@ # Corresponding source -The corresponding source for Subtitle Tools 0.1.0 is available at: +The corresponding source for Subtitle Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/subtitle-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/subtitle-tools/src/tag/v0.2.0 Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index 76b5f1f..9179d8f 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -1,12 +1,12 @@ # Third-party notices -Subtitle Tools 0.1.0 directly depends on these runtime packages: +Subtitle Tools 0.2.0 directly depends on these runtime packages: | Package | Pinned version | Declared licence | | -------------------------------- | -------------: | ---------------- | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | -| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | +| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | | `react` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT | diff --git a/public/docs/ACCESSIBILITY.md b/public/docs/ACCESSIBILITY.md index 29b8621..d321e60 100644 --- a/public/docs/ACCESSIBILITY.md +++ b/public/docs/ACCESSIBILITY.md @@ -1,6 +1,6 @@ # Accessibility -The application uses labelled native controls, keyboard-reachable tabs and cue +The application uses labelled native controls, keyboard-reachable pressed-state workspace buttons and cue buttons, visible focus indicators, text alternatives for waveform content, and light, dark and system themes. Colour is not the only carrier for issue level. The cue and issue views cap rendered rows to keep navigation responsive while diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 72b162b..f23a00c 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -2,16 +2,24 @@ Subtitle Tools is a relocatable React application with a framework-independent TypeScript subtitle model. The parser first applies byte/text and line bounds, -then produces one normalized cue model for SRT, WebVTT and ASS. Operations return -new documents instead of mutating parser state. Serializers are format-specific; -cross-format export deliberately exposes its preservation boundary. +then produces one normalized cue model for SRT, WebVTT, ASS and a bounded flat +TTML profile. XML document types/entities are rejected; TTML resources, +inherited timing and layout are never resolved. Cue structure/text/timing +operations return new documents instead of mutating parser state. Serializers +are format-specific; cross-format export deliberately exposes its preservation +boundary. -Parsing and timing transformations are synchronous in v0.1 and therefore capped +Parsing and timing transformations are synchronous in v0.2 and therefore capped at 4,000,000 UTF-16 units, 250,000 units per physical line and 100,000 cues. The editor renders at most 10,000 cue rows and 2,000 issue rows, while validation and export still cover the complete bounded document. Revision comparison is index-aligned and linear rather than an unbounded semantic diff. -The optional waveform component uses browser-native media decoding, keeps at -most 2,000 peak buckets and accepts no more than 128 MiB or four decoded hours. -It does not run a codec, speech-recognition model or network service. +The optional waveform component creates a browser-native playback URL as soon as +media is selected. Decoding continues independently and transfers one PCM +channel to a disposable module worker, which produces at most 2,000 peak +buckets. Replacement and cancellation terminate the worker, suppress stale +results and retain the last valid waveform. Zoom/navigation changes only the +bounded peak view and native playback position. Inputs remain capped at 128 MiB +and four decoded hours; no codec, speech-recognition model or network service +runs. diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md index 9bc942f..86dbb3b 100644 --- a/public/docs/PRIVACY-SECURITY.md +++ b/public/docs/PRIVACY-SECURITY.md @@ -6,9 +6,12 @@ other network destination exists. The production policy uses `connect-src 'self'`; browser tests fail on non-local requests. Subtitle text is rendered by React as text, not interpreted as HTML. ASS override -codes and WebVTT tags are preserved as inert source. Exports are downloads chosen -by the user. Browser-native media playback and Web Audio decoding inherit browser -codec support and may allocate substantial memory inside the documented bounds. +codes and WebVTT tags are preserved as inert source. TTML import rejects document +types and entity declarations and reads a deliberately flat, bounded profile; it +does not fetch referenced styles, images, fonts or other resources. Exports are +downloads chosen by the user. Browser-native media playback and Web Audio +decoding inherit browser codec support and may allocate substantial memory inside +the documented bounds. Validation findings are editorial signals. They are not proof of accessibility, broadcast conformance, synchronization quality or legal compliance. Cross-format diff --git a/public/sw.js b/public/sw.js index 00b66f0..6930b56 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "subtitle-tools-shell-"; -const CACHE_NAME = CACHE_PREFIX + "0.1.0"; +const CACHE_NAME = CACHE_PREFIX + "0.2.0"; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; self.addEventListener("install", (event) => { event.waitUntil( diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 3cf7b4c..4d51fae 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,12 +3,12 @@ "schemaVersion": 1, "id": "de.add-ideas.subtitle-tools", "name": "Subtitle Tools", - "version": "0.1.0", - "description": "Edit, validate, synchronize, compare, and convert subtitles locally.", + "version": "0.2.0", + "description": "Edit, navigate, synchronize and convert subtitles locally.", "entry": "./", "icon": "./favicon.svg", "categories": ["media", "text", "productivity"], - "tags": ["subtitles", "captions", "srt", "webvtt", "ass", "timing"], + "tags": ["subtitles", "captions", "srt", "webvtt", "ass", "ttml", "timing"], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], @@ -21,6 +21,27 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "text/plain", "extensions": [".srt", ".ass", ".ssa"] }, + { "mediaType": "text/vtt", "extensions": [".vtt"] }, + { + "mediaType": "application/ttml+xml", + "extensions": [".ttml", ".dfxp", ".xml"] + }, + { "mediaType": "audio/*", "extensions": [] }, + { "mediaType": "video/*", "extensions": [] } + ], + "produces": [ + { "mediaType": "text/plain", "extensions": [".srt", ".ass"] }, + { "mediaType": "text/vtt", "extensions": [".vtt"] }, + { "mediaType": "application/ttml+xml", "extensions": [".ttml"] } + ] + }, + "capabilities": { + "required": [], + "optional": ["workers", "web-audio", "media-playback"] + }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 38e91f5..b41de2e 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -32,8 +32,8 @@ export function HelpDialog({

- Edit, validate, synchronize, compare, and convert SRT, WebVTT, and ASS - subtitles locally in the browser. + Edit, validate, synchronize, compare, and convert SRT, WebVTT, ASS, and + flat-profile TTML subtitles locally in the browser.

Subtitle and optional media inputs are bounded and remain on this diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx index f3d25fc..60a02a2 100644 --- a/src/components/Waveform.tsx +++ b/src/components/Waveform.tsx @@ -1,28 +1,63 @@ import { useEffect, useRef, useState } from "react"; +import { DEFAULT_PEAK_COUNT, extractPeaks } from "../media/peaks"; import type { Cue } from "../subtitle/model"; +interface MediaData { + id: number; + name: string; + url: string; +} + interface WaveformData { + mediaId: number; duration: number; peaks: Float32Array; - url: string; +} + +interface ExtractionJob { + id: number; + cancelled: boolean; + context?: AudioContext; + worker?: Worker; + rejectPeaks?: (reason: DOMException) => void; } const MAX_AUDIO_BYTES = 128 * 1024 * 1024; const MAX_DURATION_SECONDS = 4 * 60 * 60; -const PEAK_COUNT = 2_000; -export function Waveform({ cues }: { cues: readonly Cue[] }) { +export function Waveform({ + cues, + selectedIndex, + onSelectCue, +}: { + cues: readonly Cue[]; + selectedIndex: number; + onSelectCue: (index: number) => void; +}) { const canvas = useRef(null); const audio = useRef(null); + const mediaRef = useRef(undefined); + const jobRef = useRef(undefined); + const sequence = useRef(0); + const [media, setMedia] = useState(); const [waveform, setWaveform] = useState(); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const [zoom, setZoom] = useState(1); + const [currentTime, setCurrentTime] = useState(0); useEffect( () => () => { - if (waveform) URL.revokeObjectURL(waveform.url); + const job = jobRef.current; + if (job) { + job.cancelled = true; + job.worker?.terminate(); + job.rejectPeaks?.(abortError()); + void job.context?.close().catch(() => undefined); + } + if (mediaRef.current) URL.revokeObjectURL(mediaRef.current.url); }, - [waveform], + [], ); useEffect(() => { @@ -39,6 +74,14 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) { const style = getComputedStyle(node); context.fillStyle = style.getPropertyValue("--wave-bg") || "#f1f2f7"; context.fillRect(0, 0, width, height); + const selectedCue = cues[selectedIndex]; + if (selectedCue) { + const x = (selectedCue.startMs / 1000 / waveform.duration) * width; + const end = (selectedCue.endMs / 1000 / waveform.duration) * width; + context.fillStyle = + style.getPropertyValue("--cue-selected") || "rgba(91,78,196,.18)"; + context.fillRect(x, 0, Math.max(1, end - x), height); + } context.strokeStyle = style.getPropertyValue("--wave-line") || "#5b4ec4"; context.beginPath(); waveform.peaks.forEach((peak, index) => { @@ -57,7 +100,78 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) { context.lineTo(x, height); context.stroke(); } - }, [cues, waveform]); + const playhead = (currentTime / waveform.duration) * width; + if (playhead >= 0 && playhead <= width) { + context.globalAlpha = 1; + context.strokeStyle = style.getPropertyValue("--playhead") || "#111827"; + context.beginPath(); + context.moveTo(playhead, 0); + context.lineTo(playhead, height); + context.stroke(); + } + }, [cues, waveform, selectedIndex, currentTime, zoom]); + + const isCurrent = (job: ExtractionJob) => + !job.cancelled && jobRef.current === job; + + const stopJob = (message?: string) => { + const job = jobRef.current; + if (!job) return; + job.cancelled = true; + job.worker?.terminate(); + job.rejectPeaks?.(abortError()); + void job.context?.close().catch(() => undefined); + jobRef.current = undefined; + setBusy(false); + if (message) setError(message); + }; + + const calculatePeaks = ( + samples: Float32Array, + job: ExtractionJob, + ): Promise => { + if (typeof Worker === "undefined") + return Promise.resolve(extractPeaks(samples, DEFAULT_PEAK_COUNT)); + return new Promise((resolve, reject) => { + const worker = new Worker( + new URL("../media/peaks.worker.ts", import.meta.url), + { type: "module" }, + ); + job.worker = worker; + job.rejectPeaks = reject; + worker.onmessage = ( + event: MessageEvent<{ + id: number; + peaks?: ArrayBuffer; + error?: string; + }>, + ) => { + if (event.data.id !== job.id || !isCurrent(job)) return; + worker.terminate(); + job.worker = undefined; + job.rejectPeaks = undefined; + if (event.data.error) reject(new Error(event.data.error)); + else if (event.data.peaks) resolve(new Float32Array(event.data.peaks)); + else reject(new Error("Peak worker returned no result.")); + }; + worker.onerror = () => { + worker.terminate(); + job.worker = undefined; + job.rejectPeaks = undefined; + reject(new Error("Peak worker failed.")); + }; + const transferable = samples.slice(); + worker.postMessage( + { + id: job.id, + samples: transferable.buffer, + length: transferable.length, + count: DEFAULT_PEAK_COUNT, + }, + [transferable.buffer], + ); + }); + }; const open = async (file: File | undefined) => { if (!file) return; @@ -65,8 +179,20 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) { setError("Audio exceeds the 128 MiB waveform limit."); return; } + + stopJob(); + const id = ++sequence.current; + const nextMedia = { id, name: file.name, url: URL.createObjectURL(file) }; + const previous = mediaRef.current; + mediaRef.current = nextMedia; + setMedia(nextMedia); + setCurrentTime(0); + if (previous) URL.revokeObjectURL(previous.url); setBusy(true); setError(""); + const job: ExtractionJob = { id, cancelled: false }; + jobRef.current = job; + try { const AudioContextConstructor = globalThis.AudioContext ?? @@ -78,51 +204,71 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) { if (!AudioContextConstructor) throw new Error("Web Audio is unavailable."); const context = new AudioContextConstructor(); - try { - const decoded = await context.decodeAudioData(await file.arrayBuffer()); - if ( - !Number.isFinite(decoded.duration) || - decoded.duration > MAX_DURATION_SECONDS - ) - throw new Error( - "Decoded audio exceeds the four-hour waveform limit.", - ); - const channel = decoded.getChannelData(0); - const peaks = new Float32Array(Math.min(PEAK_COUNT, channel.length)); - const stride = Math.max(1, Math.floor(channel.length / peaks.length)); - for (let bucket = 0; bucket < peaks.length; bucket += 1) { - let peak = 0; - const end = Math.min(channel.length, (bucket + 1) * stride); - for (let index = bucket * stride; index < end; index += 1) - peak = Math.max(peak, Math.abs(channel[index] ?? 0)); - peaks[bucket] = peak; - } - const url = URL.createObjectURL(file); - setWaveform((current) => { - if (current) URL.revokeObjectURL(current.url); - return { duration: decoded.duration, peaks, url }; - }); - } finally { - await context.close(); - } + job.context = context; + const decoded = await context.decodeAudioData(await file.arrayBuffer()); + if (!isCurrent(job)) return; + if ( + !Number.isFinite(decoded.duration) || + decoded.duration <= 0 || + decoded.duration > MAX_DURATION_SECONDS + ) + throw new Error( + "Decoded audio must have a positive duration within the four-hour waveform limit.", + ); + const peaks = await calculatePeaks(decoded.getChannelData(0), job); + if (!isCurrent(job)) return; + setWaveform({ mediaId: id, duration: decoded.duration, peaks }); } catch (reason) { - setError( - reason instanceof Error ? reason.message : "Audio decode failed.", - ); + if (isCurrent(job)) + setError( + reason instanceof Error ? reason.message : "Audio decode failed.", + ); } finally { - setBusy(false); + void job.context?.close().catch(() => undefined); + job.context = undefined; + if (isCurrent(job)) { + jobRef.current = undefined; + setBusy(false); + } } }; + const waveformMatchesMedia = Boolean( + media && waveform && media.id === waveform.mediaId, + ); const seek = (event: React.MouseEvent) => { - if (!waveform || !audio.current) return; + if (!waveform || !waveformMatchesMedia || !audio.current) return; const bounds = event.currentTarget.getBoundingClientRect(); - audio.current.currentTime = + const time = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * waveform.duration; + audio.current.currentTime = time; + setCurrentTime(time); + const containing = cues.findIndex( + (cue) => cue.startMs / 1000 <= time && cue.endMs / 1000 >= time, + ); + const nearest = cues.reduce( + (best, cue, index) => + Math.abs(cue.startMs / 1000 - time) < best.distance + ? { index, distance: Math.abs(cue.startMs / 1000 - time) } + : best, + { index: -1, distance: Number.POSITIVE_INFINITY }, + ).index; + if (containing >= 0 || nearest >= 0) + onSelectCue(containing >= 0 ? containing : nearest); void audio.current.play(); }; + const jumpToCue = (index: number) => { + const cue = cues[index]; + if (!cue) return; + onSelectCue(index); + if (audio.current) { + audio.current.currentTime = cue.startMs / 1000; + setCurrentTime(cue.startMs / 1000); + } + }; + return (

@@ -130,39 +276,128 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) {

Optional local media

Waveform synchronization

- +
+ + {busy ? ( + + ) : null} +

- Native playback is available immediately. Peak extraction is bounded and - stays in this browser; it is not speech recognition or automatic - alignment. + Native playback is attached as soon as a file is chosen. Decoding and + bounded peak extraction continue separately and never replace the last + valid waveform with an empty or failed result.

+ {busy ? ( +

+ Extracting waveform for{" "} + {media?.name}… +

+ ) : null} {error && (

{error}

)} + {media ? ( +
); } + +function abortError(): DOMException { + return new DOMException("Waveform extraction cancelled.", "AbortError"); +} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index bf26a54..5c5c8f7 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -9,10 +9,16 @@ import { compareDocuments, convertFrameRate, documentSummary, + insertCue, + mergeCueWithNext, parseSubtitle, + removeCue, replaceCue, serializeSubtitle, + snapCuesToFrames, sortCues, + splitCue, + transformCueText, transformTiming, validateDocument, type SubtitleDocument, @@ -223,7 +229,7 @@ export function Workbench() {

Subtitle Tools

Inspect, edit, synchronize, validate, compare, and convert SRT, - WebVTT, and ASS captions without uploading media or text. + WebVTT, ASS, and TTML captions without uploading media or text.

No network requests @@ -252,13 +258,12 @@ export function Workbench() { -