1 Commits
Author SHA1 Message Date
zemion 88c2bcd0ff Release Subtitle Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 08:44:49 +02:00
36 changed files with 1354 additions and 182 deletions
+39
View File
@@ -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
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added bounded SRT, WebVTT and ASS parsing, cue editing and explicit conversion. - Added bounded SRT, WebVTT and ASS parsing, cue editing and explicit conversion.
+16 -11
View File
@@ -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 browser. Subtitle Tools is an independently versioned application in the
[add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). [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 - Bounded SRT, WebVTT, ASS/SSA and flat-profile TTML/DFXP parsing
- Cue timeline, direct text/timestamp editing and configurable diagnostics for - 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 overlaps, order, duration, reading speed, line count and line length
- Shift and anchor-based stretch operations, negative-time protection and - Shift and anchor-based stretch operations, negative-time protection,
explicit source/target frame-rate conversion source/target frame-rate conversion and frame-grid snapping
- SRT, WebVTT and ASS serialization with format-specific preservation where - SRT, WebVTT, ASS and safe flat TTML serialization with format-specific preservation where
possible and a visible cross-format loss warning possible and a visible cross-format loss warning
- Deterministic index-aligned revision comparison - Deterministic index-aligned revision comparison
- Immediate browser-native audio/video playback plus bounded local waveform peak - Immediate browser-native audio/video playback while cancellable, worker-based
extraction and subtitle-start markers 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 - 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 All source, media and generated output stays in the current browser. The app
makes no external request. Validation is configurable editorial guidance, not a 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 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 ## Development
@@ -37,7 +42,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates the deterministic `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 ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # 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`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # 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 | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,6 +1,6 @@
# Accessibility # 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 buttons, visible focus indicators, text alternatives for waveform content, and
light, dark and system themes. Colour is not the only carrier for issue level. 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 The cue and issue views cap rendered rows to keep navigation responsive while
+15 -7
View File
@@ -2,16 +2,24 @@
Subtitle Tools is a relocatable React application with a framework-independent Subtitle Tools is a relocatable React application with a framework-independent
TypeScript subtitle model. The parser first applies byte/text and line bounds, 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 then produces one normalized cue model for SRT, WebVTT, ASS and a bounded flat
new documents instead of mutating parser state. Serializers are format-specific; TTML profile. XML document types/entities are rejected; TTML resources,
cross-format export deliberately exposes its preservation boundary. 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 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 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 export still cover the complete bounded document. Revision comparison is
index-aligned and linear rather than an unbounded semantic diff. index-aligned and linear rather than an unbounded semantic diff.
The optional waveform component uses browser-native media decoding, keeps at The optional waveform component creates a browser-native playback URL as soon as
most 2,000 peak buckets and accepts no more than 128 MiB or four decoded hours. media is selected. Decoding continues independently and transfers one PCM
It does not run a codec, speech-recognition model or network service. 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.
+6 -3
View File
@@ -6,9 +6,12 @@ other network destination exists. The production policy uses
`connect-src 'self'`; browser tests fail on non-local requests. `connect-src 'self'`; browser tests fail on non-local requests.
Subtitle text is rendered by React as text, not interpreted as HTML. ASS override 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 codes and WebVTT tags are preserved as inert source. TTML import rejects document
by the user. Browser-native media playback and Web Audio decoding inherit browser types and entity declarations and reads a deliberately flat, bounded profile; it
codec support and may allocate substantial memory inside the documented bounds. 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, Validation findings are editorial signals. They are not proof of accessibility,
broadcast conformance, synchronization quality or legal compliance. Cross-format broadcast conformance, synchronization quality or legal compliance. Cross-format
+22 -23
View File
@@ -1,22 +1,22 @@
{ {
"name": "subtitle-tools", "name": "subtitle-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "subtitle-tools", "name": "subtitle-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"engines": { "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"node": ">=22" "license": "GPL-3.0-or-later"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "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", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,17 +67,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "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, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "subtitle-tools", "name": "subtitle-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Edit, validate, synchronize, compare, and convert subtitles locally in the browser.", "description": "Edit, validate, synchronize, compare, and convert subtitles locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added bounded SRT, WebVTT and ASS parsing, cue editing and explicit conversion. - Added bounded SRT, WebVTT and ASS parsing, cue editing and explicit conversion.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+16 -11
View File
@@ -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 browser. Subtitle Tools is an independently versioned application in the
[add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). [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 - Bounded SRT, WebVTT, ASS/SSA and flat-profile TTML/DFXP parsing
- Cue timeline, direct text/timestamp editing and configurable diagnostics for - 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 overlaps, order, duration, reading speed, line count and line length
- Shift and anchor-based stretch operations, negative-time protection and - Shift and anchor-based stretch operations, negative-time protection,
explicit source/target frame-rate conversion source/target frame-rate conversion and frame-grid snapping
- SRT, WebVTT and ASS serialization with format-specific preservation where - SRT, WebVTT, ASS and safe flat TTML serialization with format-specific preservation where
possible and a visible cross-format loss warning possible and a visible cross-format loss warning
- Deterministic index-aligned revision comparison - Deterministic index-aligned revision comparison
- Immediate browser-native audio/video playback plus bounded local waveform peak - Immediate browser-native audio/video playback while cancellable, worker-based
extraction and subtitle-start markers 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 - 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 All source, media and generated output stays in the current browser. The app
makes no external request. Validation is configurable editorial guidance, not a 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 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 ## Development
@@ -37,7 +42,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates the deterministic `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 ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # 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`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # 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 | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,6 +1,6 @@
# Accessibility # 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 buttons, visible focus indicators, text alternatives for waveform content, and
light, dark and system themes. Colour is not the only carrier for issue level. 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 The cue and issue views cap rendered rows to keep navigation responsive while
+15 -7
View File
@@ -2,16 +2,24 @@
Subtitle Tools is a relocatable React application with a framework-independent Subtitle Tools is a relocatable React application with a framework-independent
TypeScript subtitle model. The parser first applies byte/text and line bounds, 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 then produces one normalized cue model for SRT, WebVTT, ASS and a bounded flat
new documents instead of mutating parser state. Serializers are format-specific; TTML profile. XML document types/entities are rejected; TTML resources,
cross-format export deliberately exposes its preservation boundary. 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 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 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 export still cover the complete bounded document. Revision comparison is
index-aligned and linear rather than an unbounded semantic diff. index-aligned and linear rather than an unbounded semantic diff.
The optional waveform component uses browser-native media decoding, keeps at The optional waveform component creates a browser-native playback URL as soon as
most 2,000 peak buckets and accepts no more than 128 MiB or four decoded hours. media is selected. Decoding continues independently and transfers one PCM
It does not run a codec, speech-recognition model or network service. 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.
+6 -3
View File
@@ -6,9 +6,12 @@ other network destination exists. The production policy uses
`connect-src 'self'`; browser tests fail on non-local requests. `connect-src 'self'`; browser tests fail on non-local requests.
Subtitle text is rendered by React as text, not interpreted as HTML. ASS override 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 codes and WebVTT tags are preserved as inert source. TTML import rejects document
by the user. Browser-native media playback and Web Audio decoding inherit browser types and entity declarations and reads a deliberately flat, bounded profile; it
codec support and may allocate substantial memory inside the documented bounds. 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, Validation findings are editorial signals. They are not proof of accessibility,
broadcast conformance, synchronization quality or legal compliance. Cross-format broadcast conformance, synchronization quality or legal compliance. Cross-format
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "subtitle-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+24 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.subtitle-tools", "id": "de.add-ideas.subtitle-tools",
"name": "Subtitle Tools", "name": "Subtitle Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Edit, validate, synchronize, compare, and convert subtitles locally.", "description": "Edit, navigate, synchronize and convert subtitles locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["media", "text", "productivity"], "categories": ["media", "text", "productivity"],
"tags": ["subtitles", "captions", "srt", "webvtt", "ass", "timing"], "tags": ["subtitles", "captions", "srt", "webvtt", "ass", "ttml", "timing"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,27 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function HelpDialog({
</button> </button>
</div> </div>
<p> <p>
Edit, validate, synchronize, compare, and convert SRT, WebVTT, and ASS Edit, validate, synchronize, compare, and convert SRT, WebVTT, ASS, and
subtitles locally in the browser. flat-profile TTML subtitles locally in the browser.
</p> </p>
<p> <p>
Subtitle and optional media inputs are bounded and remain on this Subtitle and optional media inputs are bounded and remain on this
+293 -58
View File
@@ -1,28 +1,63 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { DEFAULT_PEAK_COUNT, extractPeaks } from "../media/peaks";
import type { Cue } from "../subtitle/model"; import type { Cue } from "../subtitle/model";
interface MediaData {
id: number;
name: string;
url: string;
}
interface WaveformData { interface WaveformData {
mediaId: number;
duration: number; duration: number;
peaks: Float32Array; 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_AUDIO_BYTES = 128 * 1024 * 1024;
const MAX_DURATION_SECONDS = 4 * 60 * 60; 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<HTMLCanvasElement>(null); const canvas = useRef<HTMLCanvasElement>(null);
const audio = useRef<HTMLAudioElement>(null); const audio = useRef<HTMLAudioElement>(null);
const mediaRef = useRef<MediaData | undefined>(undefined);
const jobRef = useRef<ExtractionJob | undefined>(undefined);
const sequence = useRef(0);
const [media, setMedia] = useState<MediaData>();
const [waveform, setWaveform] = useState<WaveformData>(); const [waveform, setWaveform] = useState<WaveformData>();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [zoom, setZoom] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
useEffect( 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(() => { useEffect(() => {
@@ -39,6 +74,14 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) {
const style = getComputedStyle(node); const style = getComputedStyle(node);
context.fillStyle = style.getPropertyValue("--wave-bg") || "#f1f2f7"; context.fillStyle = style.getPropertyValue("--wave-bg") || "#f1f2f7";
context.fillRect(0, 0, width, height); 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.strokeStyle = style.getPropertyValue("--wave-line") || "#5b4ec4";
context.beginPath(); context.beginPath();
waveform.peaks.forEach((peak, index) => { waveform.peaks.forEach((peak, index) => {
@@ -57,7 +100,78 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) {
context.lineTo(x, height); context.lineTo(x, height);
context.stroke(); 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<Float32Array> => {
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) => { const open = async (file: File | undefined) => {
if (!file) return; if (!file) return;
@@ -65,8 +179,20 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) {
setError("Audio exceeds the 128 MiB waveform limit."); setError("Audio exceeds the 128 MiB waveform limit.");
return; 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); setBusy(true);
setError(""); setError("");
const job: ExtractionJob = { id, cancelled: false };
jobRef.current = job;
try { try {
const AudioContextConstructor = const AudioContextConstructor =
globalThis.AudioContext ?? globalThis.AudioContext ??
@@ -78,51 +204,71 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) {
if (!AudioContextConstructor) if (!AudioContextConstructor)
throw new Error("Web Audio is unavailable."); throw new Error("Web Audio is unavailable.");
const context = new AudioContextConstructor(); const context = new AudioContextConstructor();
try { job.context = context;
const decoded = await context.decodeAudioData(await file.arrayBuffer()); const decoded = await context.decodeAudioData(await file.arrayBuffer());
if ( if (!isCurrent(job)) return;
!Number.isFinite(decoded.duration) || if (
decoded.duration > MAX_DURATION_SECONDS !Number.isFinite(decoded.duration) ||
) decoded.duration <= 0 ||
throw new Error( decoded.duration > MAX_DURATION_SECONDS
"Decoded audio exceeds the four-hour waveform limit.", )
); throw new Error(
const channel = decoded.getChannelData(0); "Decoded audio must have a positive duration within the four-hour waveform limit.",
const peaks = new Float32Array(Math.min(PEAK_COUNT, channel.length)); );
const stride = Math.max(1, Math.floor(channel.length / peaks.length)); const peaks = await calculatePeaks(decoded.getChannelData(0), job);
for (let bucket = 0; bucket < peaks.length; bucket += 1) { if (!isCurrent(job)) return;
let peak = 0; setWaveform({ mediaId: id, duration: decoded.duration, peaks });
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();
}
} catch (reason) { } catch (reason) {
setError( if (isCurrent(job))
reason instanceof Error ? reason.message : "Audio decode failed.", setError(
); reason instanceof Error ? reason.message : "Audio decode failed.",
);
} finally { } 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<HTMLCanvasElement>) => { const seek = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (!waveform || !audio.current) return; if (!waveform || !waveformMatchesMedia || !audio.current) return;
const bounds = event.currentTarget.getBoundingClientRect(); const bounds = event.currentTarget.getBoundingClientRect();
audio.current.currentTime = const time =
((event.clientX - bounds.left) / Math.max(1, bounds.width)) * ((event.clientX - bounds.left) / Math.max(1, bounds.width)) *
waveform.duration; 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(); 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 ( return (
<section className="waveform-workspace"> <section className="waveform-workspace">
<div className="panel-heading"> <div className="panel-heading">
@@ -130,39 +276,128 @@ export function Waveform({ cues }: { cues: readonly Cue[] }) {
<p className="eyebrow">Optional local media</p> <p className="eyebrow">Optional local media</p>
<h2>Waveform synchronization</h2> <h2>Waveform synchronization</h2>
</div> </div>
<label className="file-button"> <div className="button-row">
{busy ? "Decoding…" : "Open audio"} <label className="file-button">
<input {media ? "Replace media" : "Open media"}
type="file" <input
accept="audio/*,video/*" type="file"
disabled={busy} accept="audio/*,video/*"
onChange={(event) => void open(event.target.files?.[0])} onChange={(event) => void open(event.target.files?.[0])}
/> />
</label> </label>
{busy ? (
<button
type="button"
onClick={() =>
stopJob(
"Waveform extraction cancelled. Playback remains available.",
)
}
>
Cancel waveform
</button>
) : null}
</div>
</div> </div>
<p className="muted"> <p className="muted">
Native playback is available immediately. Peak extraction is bounded and Native playback is attached as soon as a file is chosen. Decoding and
stays in this browser; it is not speech recognition or automatic bounded peak extraction continue separately and never replace the last
alignment. valid waveform with an empty or failed result.
</p> </p>
{busy ? (
<p role="status">
<progress aria-label="Waveform extraction" /> Extracting waveform for{" "}
{media?.name}
</p>
) : null}
{error && ( {error && (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}
</p> </p>
)} )}
{media ? (
<audio
ref={audio}
src={media.url}
controls
preload="metadata"
onTimeUpdate={(event) =>
setCurrentTime(event.currentTarget.currentTime)
}
/>
) : null}
{waveform ? ( {waveform ? (
<> <>
<audio ref={audio} src={waveform.url} controls preload="metadata" /> {media && !waveformMatchesMedia ? (
<canvas <p className="muted">
ref={canvas} Showing the previous valid waveform until the new one is ready.
className="waveform" </p>
aria-label="Audio waveform with subtitle start markers" ) : null}
onClick={seek} <div className="waveform-navigation">
/> <button
type="button"
disabled={!waveformMatchesMedia || selectedIndex <= 0}
onClick={() => jumpToCue(selectedIndex - 1)}
>
Previous cue
</button>
<button
type="button"
disabled={
!waveformMatchesMedia || selectedIndex + 1 >= cues.length
}
onClick={() => jumpToCue(selectedIndex + 1)}
>
Next cue
</button>
<label>
Waveform zoom
<input
type="range"
min="1"
max="8"
step="0.5"
value={zoom}
onChange={(event) => setZoom(event.target.valueAsNumber)}
/>
<output>{zoom.toFixed(1)}×</output>
</label>
<output aria-label="Playback time">
{currentTime.toFixed(2)} s
</output>
</div>
<div className="waveform-scroll">
<canvas
ref={canvas}
className="waveform"
style={{ width: `${zoom * 100}%` }}
aria-label="Audio waveform with subtitle start markers"
aria-disabled={!waveformMatchesMedia}
tabIndex={waveformMatchesMedia ? 0 : -1}
onClick={seek}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
jumpToCue(Math.max(0, selectedIndex - 1));
} else if (event.key === "ArrowRight") {
event.preventDefault();
jumpToCue(Math.min(cues.length - 1, selectedIndex + 1));
}
}}
/>
</div>
</> </>
) : media ? (
<p className="empty">
Playback is ready; waveform extraction is still pending.
</p>
) : ( ) : (
<p className="empty">Choose a browser-decodable audio or video file.</p> <p className="empty">Choose a browser-decodable audio or video file.</p>
)} )}
</section> </section>
); );
} }
function abortError(): DOMException {
return new DOMException("Waveform extraction cancelled.", "AbortError");
}
+195 -14
View File
@@ -9,10 +9,16 @@ import {
compareDocuments, compareDocuments,
convertFrameRate, convertFrameRate,
documentSummary, documentSummary,
insertCue,
mergeCueWithNext,
parseSubtitle, parseSubtitle,
removeCue,
replaceCue, replaceCue,
serializeSubtitle, serializeSubtitle,
snapCuesToFrames,
sortCues, sortCues,
splitCue,
transformCueText,
transformTiming, transformTiming,
validateDocument, validateDocument,
type SubtitleDocument, type SubtitleDocument,
@@ -223,7 +229,7 @@ export function Workbench() {
<h1>Subtitle Tools</h1> <h1>Subtitle Tools</h1>
<p> <p>
Inspect, edit, synchronize, validate, compare, and convert SRT, 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.
</p> </p>
</div> </div>
<span className="privacy-pill">No network requests</span> <span className="privacy-pill">No network requests</span>
@@ -252,13 +258,12 @@ export function Workbench() {
</div> </div>
</section> </section>
<nav className="tabs" role="tablist" aria-label="Subtitle workspaces"> <nav className="tabs" aria-label="Subtitle workspaces">
{tabs.map(([value, label]) => ( {tabs.map(([value, label]) => (
<button <button
key={value} key={value}
type="button" type="button"
role="tab" aria-pressed={tab === value}
aria-selected={tab === value}
onClick={() => setTab(value)} onClick={() => setTab(value)}
> >
{label} {label}
@@ -284,7 +289,7 @@ export function Workbench() {
Open file Open file
<input <input
type="file" type="file"
accept=".srt,.vtt,.ass,.ssa,text/plain,text/vtt" accept=".srt,.vtt,.ass,.ssa,.ttml,.dfxp,.xml,text/plain,text/vtt,application/ttml+xml"
onChange={(event) => void open(event.target.files?.[0])} onChange={(event) => void open(event.target.files?.[0])}
/> />
</label> </label>
@@ -302,6 +307,7 @@ export function Workbench() {
<option value="srt">SubRip (SRT)</option> <option value="srt">SubRip (SRT)</option>
<option value="vtt">WebVTT</option> <option value="vtt">WebVTT</option>
<option value="ass">ASS / SSA</option> <option value="ass">ASS / SSA</option>
<option value="ttml">TTML / DFXP</option>
</select> </select>
</label> </label>
<label className="field"> <label className="field">
@@ -351,6 +357,7 @@ export function Workbench() {
<option value="srt">SubRip (SRT)</option> <option value="srt">SubRip (SRT)</option>
<option value="vtt">WebVTT</option> <option value="vtt">WebVTT</option>
<option value="ass">Advanced SubStation Alpha</option> <option value="ass">Advanced SubStation Alpha</option>
<option value="ttml">TTML</option>
</select> </select>
</label> </label>
{exportFormat !== document.format && ( {exportFormat !== document.format && (
@@ -378,12 +385,55 @@ export function Workbench() {
<p className="eyebrow">Timeline</p> <p className="eyebrow">Timeline</p>
<h2>Cues</h2> <h2>Cues</h2>
</div> </div>
<button <div className="button-row">
type="button" <button
onClick={() => setDocument(sortCues(document))} type="button"
> onClick={() => setDocument(sortCues(document))}
Sort by start >
</button> Sort by start
</button>
<button
type="button"
onClick={() => {
try {
setDocument(insertCue(document, selected + 1));
setSelected(Math.min(selected + 1, document.cues.length));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: String(reason),
);
}
}}
>
Add cue
</button>
</div>
</div>
<div className="timeline-strip" aria-label="Editable cue timeline">
{document.cues.slice(0, 2_000).map((cue, index) => {
const start = summary.firstMs;
const span = Math.max(1, summary.lastMs - start);
const left = ((cue.startMs - start) / span) * 100;
const width = ((cue.endMs - cue.startMs) / span) * 100;
return (
<button
type="button"
key={`timeline-${cue.sourceLine}-${index}`}
className={selected === index ? "selected" : ""}
style={{
left: `${Math.max(0, Math.min(100, left))}%`,
width: `${Math.max(0.2, Math.min(100 - left, width))}%`,
top: `${(index % 4) * 1.35 + 0.3}rem`,
}}
title={`Cue ${index + 1}: ${clock(cue.startMs)} to ${clock(cue.endMs)}`}
aria-label={`Select cue ${index + 1} on timeline`}
onClick={() => setSelected(index)}
/>
);
})}
</div> </div>
<ol> <ol>
{document.cues.slice(0, 10_000).map((cue, index) => ( {document.cues.slice(0, 10_000).map((cue, index) => (
@@ -421,6 +471,91 @@ export function Workbench() {
</div> </div>
{selectedCue ? ( {selectedCue ? (
<> <>
<div className="button-row cue-actions">
<button
type="button"
onClick={() => {
try {
setDocument(
insertCue(document, selected + 1, {
startMs: selectedCue.startMs,
endMs: selectedCue.endMs,
text: selectedCue.text,
}),
);
setSelected(selected + 1);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: String(reason),
);
}
}}
>
Duplicate
</button>
<button
type="button"
onClick={() => {
try {
setDocument(splitCue(document, selected));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: String(reason),
);
}
}}
>
Split midpoint
</button>
<button
type="button"
disabled={selected + 1 >= document.cues.length}
onClick={() => {
try {
setDocument(mergeCueWithNext(document, selected));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: String(reason),
);
}
}}
>
Merge next
</button>
<button
type="button"
onClick={() => {
try {
const next = removeCue(document, selected);
setDocument(next);
setSelected(
Math.max(
0,
Math.min(selected, next.cues.length - 1),
),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: String(reason),
);
}
}}
>
Delete
</button>
</div>
<div className="form-grid"> <div className="form-grid">
<NumberField <NumberField
label="Start (ms)" label="Start (ms)"
@@ -456,6 +591,31 @@ export function Workbench() {
} }
/> />
</label> </label>
<div
className="button-row cue-actions"
aria-label="Cue formatting actions"
>
{(
[
["trim", "Trim"],
["strip-markup", "Strip markup"],
["upper", "Uppercase"],
["lower", "Lowercase"],
] as const
).map(([transform, label]) => (
<button
key={transform}
type="button"
onClick={() =>
setDocument(
transformCueText(document, transform, [selected]),
)
}
>
{label}
</button>
))}
</div>
</> </>
) : ( ) : (
<p className="empty">No cue selected.</p> <p className="empty">No cue selected.</p>
@@ -599,6 +759,21 @@ export function Workbench() {
<button className="primary" type="button" onClick={applyFrameRate}> <button className="primary" type="button" onClick={applyFrameRate}>
Convert frame rate Convert frame rate
</button> </button>
<button
type="button"
onClick={() => {
try {
setDocument(snapCuesToFrames(document, targetFps));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : String(reason),
);
}
}}
>
Snap cues to target frames
</button>
</article> </article>
</section> </section>
)} )}
@@ -631,7 +806,7 @@ export function Workbench() {
</div> </div>
</div> </div>
<p className="muted"> <p className="muted">
v0.1 compares cue positions deterministically. Insertions can v0.2 compares cue positions deterministically. Insertions can
shift later matches; no semantic alignment is claimed. shift later matches; no semantic alignment is claimed.
</p> </p>
{revisionDocument ? ( {revisionDocument ? (
@@ -667,7 +842,11 @@ export function Workbench() {
{tab === "waveform" && ( {tab === "waveform" && (
<section className="panel"> <section className="panel">
<Waveform cues={document.cues} /> <Waveform
cues={document.cues}
selectedIndex={selected}
onSelectCue={setSelected}
/>
</section> </section>
)} )}
@@ -676,7 +855,9 @@ export function Workbench() {
Parsing and media decoding stay local. Validation is configurable Parsing and media decoding stay local. Validation is configurable
guidance, not a broadcaster, accessibility, or legal compliance guidance, not a broadcaster, accessibility, or legal compliance
verdict. ASS round trips retain parsed dialogue fields; cross-format verdict. ASS round trips retain parsed dialogue fields; cross-format
exports explicitly discard unsupported styling. exports explicitly discard unsupported styling. TTML support is a
bounded flat cue profile; inherited timing/layout and external
resources are not interpreted.
</p> </p>
</section> </section>
</main> </main>
+25
View File
@@ -0,0 +1,25 @@
export const DEFAULT_PEAK_COUNT = 2_000;
/** Collapse PCM samples into deterministic absolute-amplitude buckets. */
export function extractPeaks(
samples: Float32Array,
requestedCount = DEFAULT_PEAK_COUNT,
): Float32Array {
if (!Number.isSafeInteger(requestedCount) || requestedCount < 1)
throw new RangeError("Peak count must be a positive whole number.");
if (samples.length === 0) return new Float32Array();
const count = Math.min(requestedCount, samples.length);
const peaks = new Float32Array(count);
for (let bucket = 0; bucket < count; bucket += 1) {
const start = Math.floor((bucket * samples.length) / count);
const end = Math.max(
start + 1,
Math.floor(((bucket + 1) * samples.length) / count),
);
let peak = 0;
for (let index = start; index < end; index += 1)
peak = Math.max(peak, Math.abs(samples[index] ?? 0));
peaks[bucket] = peak;
}
return peaks;
}
+28
View File
@@ -0,0 +1,28 @@
/// <reference lib="webworker" />
import { extractPeaks } from "./peaks";
interface PeakRequest {
id: number;
samples: ArrayBuffer;
length: number;
count: number;
}
declare const self: DedicatedWorkerGlobalScope;
self.onmessage = (event: MessageEvent<PeakRequest>) => {
const { id, samples, length, count } = event.data;
try {
const peaks = extractPeaks(new Float32Array(samples, 0, length), count);
self.postMessage({ id, peaks: peaks.buffer }, [peaks.buffer]);
} catch (reason) {
self.postMessage({
id,
error:
reason instanceof Error ? reason.message : "Peak extraction failed.",
});
}
};
export {};
+68 -1
View File
@@ -22,6 +22,8 @@
--wave-bg: #eeedf7; --wave-bg: #eeedf7;
--wave-line: #5b4ec4; --wave-line: #5b4ec4;
--cue-line: #ce3d59; --cue-line: #ce3d59;
--cue-selected: rgb(91 78 196 / 18%);
--playhead: #171522;
} }
:root[data-toolbox-theme="dark"] { :root[data-toolbox-theme="dark"] {
@@ -39,6 +41,8 @@
--wave-bg: #292634; --wave-bg: #292634;
--wave-line: #aa9cff; --wave-line: #aa9cff;
--cue-line: #ff8195; --cue-line: #ff8195;
--cue-selected: rgb(169 156 255 / 24%);
--playhead: #fff;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -57,6 +61,8 @@
--wave-bg: #292634; --wave-bg: #292634;
--wave-line: #aa9cff; --wave-line: #aa9cff;
--cue-line: #ff8195; --cue-line: #ff8195;
--cue-selected: rgb(169 156 255 / 24%);
--playhead: #fff;
} }
} }
@@ -237,7 +243,7 @@ h2 {
white-space: nowrap; white-space: nowrap;
} }
.tabs button[aria-selected="true"] { .tabs button[aria-pressed="true"] {
border-color: var(--accent); border-color: var(--accent);
background: var(--surface-muted); background: var(--surface-muted);
color: var(--accent-dark); color: var(--accent-dark);
@@ -364,6 +370,51 @@ h2 {
margin-top: 0.2rem; margin-top: 0.2rem;
} }
.button-row,
.waveform-navigation {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.cue-actions {
margin-bottom: 0.75rem;
}
.timeline-strip {
position: relative;
height: 5.9rem;
overflow: hidden;
margin-bottom: 0.75rem;
border: 1px solid var(--line);
border-radius: 0.72rem;
background:
linear-gradient(
90deg,
transparent 24.8%,
var(--line) 25%,
transparent 25.2%
)
0 0 / 25% 100%,
var(--surface-muted);
}
.timeline-strip button {
position: absolute;
min-width: 0.2rem;
min-height: 1rem;
border-radius: 0.25rem;
padding: 0;
background: color-mix(in srgb, var(--accent) 55%, var(--surface));
}
.timeline-strip button.selected {
z-index: 1;
border-color: var(--danger);
background: var(--accent);
}
.cue-list { .cue-list {
max-height: 52rem; max-height: 52rem;
overflow: auto; overflow: auto;
@@ -469,7 +520,23 @@ h2 {
margin-bottom: 0.7rem; margin-bottom: 0.7rem;
} }
.waveform-navigation {
margin: 0.65rem 0;
}
.waveform-navigation label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.waveform-scroll {
max-width: 100%;
overflow-x: auto;
}
.waveform { .waveform {
min-width: 100%;
height: 180px; height: 180px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.75rem; border-radius: 0.75rem;
+342 -3
View File
@@ -1,4 +1,4 @@
export type SubtitleFormat = "srt" | "vtt" | "ass"; export type SubtitleFormat = "srt" | "vtt" | "ass" | "ttml";
export interface Cue { export interface Cue {
id: string; id: string;
@@ -7,6 +7,7 @@ export interface Cue {
text: string; text: string;
settings?: string; settings?: string;
assFields?: Record<string, string>; assFields?: Record<string, string>;
ttmlAttributes?: Record<string, string>;
sourceLine: number; sourceLine: number;
} }
@@ -58,6 +59,7 @@ export function detectFormat(source: string): SubtitleFormat {
const head = source.replace(/^\uFEFF/u, "").slice(0, 16_384); const head = source.replace(/^\uFEFF/u, "").slice(0, 16_384);
if (/^WEBVTT(?:[ \t]|$)/mu.test(head)) return "vtt"; if (/^WEBVTT(?:[ \t]|$)/mu.test(head)) return "vtt";
if (/^\s*\[(?:Script Info|Events|V4\+? Styles)\]/imu.test(head)) return "ass"; if (/^\s*\[(?:Script Info|Events|V4\+? Styles)\]/imu.test(head)) return "ass";
if (/^\s*<\?xml[\s\S]*?<tt\b|^\s*<tt\b/iu.test(head)) return "ttml";
return "srt"; return "srt";
} }
@@ -280,6 +282,139 @@ function parseAss(source: string): SubtitleDocument {
return { format: "ass", cues, preamble, assFormat: format, warnings }; return { format: "ass", cues, preamble, assFormat: format, warnings };
} }
function parseTtmlTime(value: string, frameRate: number): number {
const clock = /^(\d{1,6}):(\d{2}):(\d{2})(?:[.](\d{1,3})|:(\d{1,4}))?$/u.exec(
value.trim(),
);
if (clock) {
const hours = Number(clock[1]),
minutes = Number(clock[2]),
seconds = Number(clock[3]);
if (minutes > 59 || seconds > 59)
throw new Error(
`TTML timestamp component is outside its range: ${value}`,
);
const fraction = clock[4]
? Number(clock[4]!.padEnd(3, "0"))
: clock[5]
? Math.round((Number(clock[5]) / frameRate) * 1000)
: 0;
const result = ((hours * 60 + minutes) * 60 + seconds) * 1000 + fraction;
if (!Number.isSafeInteger(result))
throw new Error("Timestamp is too large.");
return result;
}
const offset = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(h|m|s|ms|f)$/u.exec(
value.trim(),
);
if (!offset) throw new Error(`Invalid TTML time expression: ${value}`);
const amount = Number(offset[1]),
multiplier =
offset[2] === "h"
? 3_600_000
: offset[2] === "m"
? 60_000
: offset[2] === "s"
? 1000
: offset[2] === "f"
? 1000 / frameRate
: 1,
result = Math.round(amount * multiplier);
if (!Number.isSafeInteger(result)) throw new Error("Timestamp is too large.");
return result;
}
function ttmlCueText(node: Element): string {
const visit = (current: Node): string => {
if (current.nodeType === 3 || current.nodeType === 4)
return current.nodeValue ?? "";
if (current.nodeType !== 1) return "";
const element = current as Element;
if (element.localName.toLowerCase() === "br") return "\n";
return [...element.childNodes].map((child) => visit(child)).join("");
};
return [...node.childNodes]
.map((child) => visit(child))
.join("")
.replace(/[ \t]+/gu, " ")
.replace(/ *\n */gu, "\n")
.trim();
}
function parseTtml(source: string): SubtitleDocument {
if (/<!DOCTYPE|<!ENTITY/iu.test(source))
throw new Error("TTML document types and entities are not supported.");
const xml = new DOMParser().parseFromString(source, "application/xml");
const parserError = xml.getElementsByTagName("parsererror")[0];
if (parserError) throw new Error("TTML is not well-formed XML.");
if (xml.documentElement.localName.toLowerCase() !== "tt")
throw new Error("TTML root element must be <tt>.");
const frameRateText =
xml.documentElement.getAttribute("ttp:frameRate") ??
[...xml.documentElement.attributes].find(
(attribute) => attribute.localName === "frameRate",
)?.value,
frameRate = frameRateText ? Number(frameRateText) : 30;
if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 1000)
throw new Error("TTML frameRate must be in (0, 1000].");
const cues: Cue[] = [],
warnings: string[] = [];
const paragraphs = [...xml.getElementsByTagNameNS("*", "p")];
for (const [index, paragraph] of paragraphs.entries()) {
try {
const begin = paragraph.getAttribute("begin");
if (!begin) throw new Error("cue has no begin time");
const startMs = parseTtmlTime(begin, frameRate),
end = paragraph.getAttribute("end"),
duration = paragraph.getAttribute("dur"),
endMs = end
? parseTtmlTime(end, frameRate)
: duration
? startMs + parseTtmlTime(duration, frameRate)
: Number.NaN;
if (!Number.isSafeInteger(endMs))
throw new Error("cue has no end/dur time");
const attributes = Object.fromEntries(
[...paragraph.attributes]
.filter(
(attribute) =>
!["begin", "end", "dur"].includes(attribute.localName) &&
attribute.name.length <= 128 &&
attribute.value.length <= 4_096,
)
.slice(0, 32)
.map((attribute) => [attribute.name, attribute.value]),
);
cues.push({
id:
paragraph.getAttribute("xml:id") ??
paragraph.getAttribute("id") ??
String(index + 1),
startMs,
endMs,
text: ttmlCueText(paragraph),
ttmlAttributes: attributes,
sourceLine: index + 1,
});
} catch (reason) {
warnings.push(
`Ignored TTML cue ${index + 1}: ${reason instanceof Error ? reason.message : "invalid timing"}.`,
);
}
if (cues.length > MAX_CUES)
throw new Error("Subtitle exceeds 100,000 cues.");
}
return {
format: "ttml",
cues,
preamble: [],
warnings: [
"TTML styling, regions and inherited timing are inspected only at cue-reference level; export uses a safe flat TTML profile.",
...warnings,
],
};
}
export function parseSubtitle( export function parseSubtitle(
source: string, source: string,
requested: SubtitleFormat | "auto" = "auto", requested: SubtitleFormat | "auto" = "auto",
@@ -291,7 +426,9 @@ export function parseSubtitle(
? parseVtt(normalized) ? parseVtt(normalized)
: format === "ass" : format === "ass"
? parseAss(normalized) ? parseAss(normalized)
: parseSrt(normalized); : format === "ttml"
? parseTtml(normalized)
: parseSrt(normalized);
if (!document.cues.length) if (!document.cues.length)
document.warnings.push("No valid subtitle cues were found."); document.warnings.push("No valid subtitle cues were found.");
return document; return document;
@@ -518,6 +655,172 @@ export function replaceCue(
return cloneDocument(document, cues); return cloneDocument(document, cues);
} }
export function insertCue(
document: SubtitleDocument,
index: number,
cue?: Partial<Pick<Cue, "startMs" | "endMs" | "text">>,
): SubtitleDocument {
if (!Number.isSafeInteger(index) || index < 0 || index > document.cues.length)
throw new Error("Cue insertion index is outside the document.");
if (document.cues.length >= MAX_CUES)
throw new Error("Subtitle exceeds 100,000 cues.");
const previous = document.cues[index - 1],
next = document.cues[index],
startMs =
cue?.startMs ??
(previous
? previous.endMs + 1
: next
? Math.max(0, next.startMs - 2_000)
: 0),
endMs =
cue?.endMs ?? Math.max(startMs + 1_000, next?.startMs ?? startMs + 2_000);
if (
![startMs, endMs].every(Number.isSafeInteger) ||
startMs < 0 ||
endMs <= startMs
)
throw new Error(
"Inserted cue requires safe, non-negative increasing times.",
);
const cues = [...document.cues];
cues.splice(index, 0, {
id: `cue-${index + 1}`,
startMs,
endMs,
text: cue?.text ?? "",
sourceLine: 0,
});
return cloneDocument(document, cues);
}
export function removeCue(
document: SubtitleDocument,
index: number,
): SubtitleDocument {
if (
!Number.isSafeInteger(index) ||
index < 0 ||
index >= document.cues.length
)
throw new Error("Cue index is outside the document.");
return cloneDocument(
document,
document.cues.filter((_, cueIndex) => cueIndex !== index),
);
}
export function splitCue(
document: SubtitleDocument,
index: number,
atMs?: number,
): SubtitleDocument {
const cue = document.cues[index];
if (!cue) throw new Error("Cue index is outside the document.");
if (document.cues.length >= MAX_CUES)
throw new Error("Subtitle exceeds 100,000 cues.");
const splitAt = atMs ?? Math.round((cue.startMs + cue.endMs) / 2);
if (
!Number.isSafeInteger(splitAt) ||
splitAt <= cue.startMs ||
splitAt >= cue.endMs
)
throw new Error("Split time must be strictly inside the cue.");
const parts = splitText(cue.text),
cues = [...document.cues];
cues.splice(
index,
1,
{ ...cue, endMs: splitAt, text: parts[0] },
{
...cue,
id: `${cue.id || index + 1}-b`,
startMs: splitAt,
text: parts[1],
sourceLine: 0,
},
);
return cloneDocument(document, cues);
}
export function mergeCueWithNext(
document: SubtitleDocument,
index: number,
): SubtitleDocument {
const cue = document.cues[index],
next = document.cues[index + 1];
if (!cue || !next) throw new Error("A following cue is required to merge.");
const cues = [...document.cues];
cues.splice(index, 2, {
...cue,
endMs: Math.max(cue.endMs, next.endMs),
text: [cue.text.trim(), next.text.trim()].filter(Boolean).join("\n"),
});
return cloneDocument(document, cues);
}
export type CueTextTransform = "trim" | "strip-markup" | "upper" | "lower";
export function transformCueText(
document: SubtitleDocument,
transform: CueTextTransform,
indices?: readonly number[],
): SubtitleDocument {
const selected = indices ? new Set(indices) : undefined;
const cues = document.cues.map((cue, index) => {
if (selected && !selected.has(index)) return cue;
const text =
transform === "trim"
? cue.text
.split("\n")
.map((line) => line.trim())
.join("\n")
.trim()
: transform === "strip-markup"
? plainText(cue.text)
: transform === "upper"
? cue.text.toLocaleUpperCase()
: cue.text.toLocaleLowerCase();
return { ...cue, text };
});
return cloneDocument(document, cues);
}
export function snapCuesToFrames(
document: SubtitleDocument,
framesPerSecond: number,
): SubtitleDocument {
if (
!Number.isFinite(framesPerSecond) ||
framesPerSecond <= 0 ||
framesPerSecond > 1000
)
throw new Error("Frame rate must be in (0, 1000].");
const frameMs = 1000 / framesPerSecond;
return cloneDocument(
document,
document.cues.map((cue) => {
const startMs = Math.max(0, Math.round(cue.startMs / frameMs) * frameMs),
endMs = Math.max(
startMs + 1,
Math.round(cue.endMs / frameMs) * frameMs,
);
return { ...cue, startMs: Math.round(startMs), endMs: Math.round(endMs) };
}),
);
}
function splitText(value: string): [string, string] {
const lines = value.split("\n");
if (lines.length > 1) {
const middle = Math.ceil(lines.length / 2);
return [lines.slice(0, middle).join("\n"), lines.slice(middle).join("\n")];
}
const words = value.trim().split(/\s+/u);
if (words.length < 2) return [value, ""];
const middle = Math.ceil(words.length / 2);
return [words.slice(0, middle).join(" "), words.slice(middle).join(" ")];
}
function formatTimestamp(milliseconds: number, separator: "," | "."): string { function formatTimestamp(milliseconds: number, separator: "," | "."): string {
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) if (!Number.isSafeInteger(milliseconds) || milliseconds < 0)
throw new Error("Cannot serialize a negative or unsafe timestamp."); throw new Error("Cannot serialize a negative or unsafe timestamp.");
@@ -626,6 +929,26 @@ function serializeAss(document: SubtitleDocument): string {
return `${preamble.join("\n").replace(/\n+$/u, "")}\n${dialogue.join("\n")}\n`; return `${preamble.join("\n").replace(/\n+$/u, "")}\n${dialogue.join("\n")}\n`;
} }
function serializeTtml(document: SubtitleDocument): string {
const body = document.cues
.map((cue) => {
const attributes = Object.entries(cue.ttmlAttributes ?? {})
.filter(
([name, value]) =>
!["begin", "end", "dur", "xml:id", "id"].includes(name) &&
/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(name) &&
value.length <= 4_096,
)
.slice(0, 32)
.map(([name, value]) => ` ${name}="${xml(value)}"`)
.join("");
const id = cue.id ? ` xml:id="${xmlId(cue.id)}"` : "";
return ` <p${id} begin="${formatTimestamp(cue.startMs, ".")}" end="${formatTimestamp(cue.endMs, ".")}"${attributes}>${xml(cue.text).replaceAll("\n", "<br/>")}</p>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<tt xmlns="http://www.w3.org/ns/ttml">\n <body>\n <div>\n${body}\n </div>\n </body>\n</tt>\n`;
}
export function serializeSubtitle( export function serializeSubtitle(
document: SubtitleDocument, document: SubtitleDocument,
format: SubtitleFormat, format: SubtitleFormat,
@@ -636,7 +959,23 @@ export function serializeSubtitle(
? serializeVtt(document) ? serializeVtt(document)
: format === "ass" : format === "ass"
? serializeAss(document) ? serializeAss(document)
: serializeSrt(document); : format === "ttml"
? serializeTtml(document)
: serializeSrt(document);
}
function xml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function xmlId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]+/gu, "-").slice(0, 128);
return /^[A-Za-z_]/u.test(safe) ? safe : `cue-${safe || "1"}`;
} }
export interface RevisionChange { export interface RevisionChange {
+24 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.subtitle-tools", "id": "de.add-ideas.subtitle-tools",
"name": "Subtitle Tools", "name": "Subtitle Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Edit, validate, synchronize, compare, and convert subtitles locally.", "description": "Edit, navigate, synchronize and convert subtitles locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["media", "text", "productivity"], "categories": ["media", "text", "productivity"],
"tags": ["subtitles", "captions", "srt", "webvtt", "ass", "timing"], "tags": ["subtitles", "captions", "srt", "webvtt", "ass", "ttml", "timing"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,27 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+74 -5
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { Buffer } from "node:buffer";
const ORIGIN = "http://127.0.0.1:4197"; const ORIGIN = "http://127.0.0.1:4197";
async function localOnly(page: Page) { async function localOnly(page: Page) {
@@ -36,10 +37,10 @@ test("parses, validates, edits and converts the example", async ({ page }) => {
const external = await localOnly(page); const external = await localOnly(page);
await page.goto("/deep/nested/subtitle/"); await page.goto("/deep/nested/subtitle/");
await expect(page.getByText("SRT", { exact: true })).toBeVisible(); await expect(page.getByText("SRT", { exact: true })).toBeVisible();
await page.getByRole("tab", { name: "Cues & checks" }).click(); await page.getByRole("button", { name: "Cues & checks" }).click();
await expect(page.getByText("overlap", { exact: true })).toBeVisible(); await expect(page.getByText("overlap", { exact: true })).toBeVisible();
await page.getByLabel("Text").fill("Edited locally"); await page.getByLabel("Text").fill("Edited locally");
await page.getByRole("tab", { name: "Source & export" }).click(); await page.getByRole("button", { name: "Source & export" }).click();
await page.getByLabel("Output format").selectOption("vtt"); await page.getByLabel("Output format").selectOption("vtt");
await expect(page.getByLabel("Export preview")).toHaveValue(/WEBVTT/u); await expect(page.getByLabel("Export preview")).toHaveValue(/WEBVTT/u);
await expect(page.getByLabel("Export preview")).toHaveValue( await expect(page.getByLabel("Export preview")).toHaveValue(
@@ -50,13 +51,57 @@ test("parses, validates, edits and converts the example", async ({ page }) => {
test("applies a bounded timing shift", async ({ page }) => { test("applies a bounded timing shift", async ({ page }) => {
await page.goto("/deep/nested/subtitle/"); await page.goto("/deep/nested/subtitle/");
await page.getByRole("tab", { name: "Timing" }).click(); await page.getByRole("button", { name: "Timing" }).click();
await page.getByLabel("Shift (ms)").fill("500"); await page.getByLabel("Shift (ms)").fill("500");
await page.getByRole("button", { name: "Apply timing transform" }).click(); await page.getByRole("button", { name: "Apply timing transform" }).click();
await page.getByRole("tab", { name: "Cues & checks" }).click(); await page.getByRole("button", { name: "Cues & checks" }).click();
await expect(page.getByText(/00:00:01\.500/u).first()).toBeVisible(); await expect(page.getByText(/00:00:01\.500/u).first()).toBeVisible();
}); });
test("imports flat TTML and performs timeline edits", async ({ page }) => {
await page.goto("/deep/nested/subtitle/");
await page
.getByLabel("Subtitle source")
.fill(
`<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="3s">One<br/>line</p></div></body></tt>`,
);
await page.getByRole("button", { name: "Parse source" }).click();
await expect(
page.getByLabel("Subtitle summary").getByText("TTML", { exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Cues & checks" }).click();
await page.getByRole("button", { name: "Duplicate" }).click();
await expect(
page
.getByLabel("Subtitle summary")
.locator(":scope > div")
.filter({ hasText: /^Cues2$/u })
.locator("strong"),
).toHaveText("2");
await page.getByRole("button", { name: "Uppercase" }).click();
await page.getByRole("button", { name: "Source & export" }).click();
await page.getByLabel("Output format").selectOption("ttml");
await expect(page.getByLabel("Export preview")).toHaveValue(/<tt/u);
await expect(page.getByLabel("Export preview")).toHaveValue(/ONE/u);
});
test("attaches playback before completing worker waveform extraction", async ({
page,
}) => {
await page.goto("/deep/nested/subtitle/");
await page.getByRole("button", { name: "Waveform" }).click();
await page.getByLabel("Open media").setInputFiles({
name: "tone.wav",
mimeType: "audio/wav",
buffer: wavFixture(),
});
await expect(page.locator("audio")).toBeVisible();
await expect(
page.getByLabel("Audio waveform with subtitle start markers"),
).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
});
test("serves release identity and hardened local-only headers", async ({ test("serves release identity and hardened local-only headers", async ({
request, request,
}) => { }) => {
@@ -72,8 +117,32 @@ test("serves release identity and hardened local-only headers", async ({
const manifest = await request.get("/deep/nested/subtitle/toolbox-app.json"); const manifest = await request.get("/deep/nested/subtitle/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.subtitle-tools", id: "de.add-ideas.subtitle-tools",
version: "0.1.0", version: "0.2.0",
entry: "./", entry: "./",
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
}); });
function wavFixture(): Buffer {
const sampleRate = 8_000;
const samples = sampleRate / 4;
const output = Buffer.alloc(44 + samples * 2);
output.write("RIFF", 0);
output.writeUInt32LE(output.length - 8, 4);
output.write("WAVEfmt ", 8);
output.writeUInt32LE(16, 16);
output.writeUInt16LE(1, 20);
output.writeUInt16LE(1, 22);
output.writeUInt32LE(sampleRate, 24);
output.writeUInt32LE(sampleRate * 2, 28);
output.writeUInt16LE(2, 32);
output.writeUInt16LE(16, 34);
output.write("data", 36);
output.writeUInt32LE(samples * 2, 40);
for (let index = 0; index < samples; index += 1)
output.writeInt16LE(
Math.round(Math.sin((index / sampleRate) * Math.PI * 2 * 440) * 12_000),
44 + index * 2,
);
return output;
}
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/subtitle/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1 -1
View File
@@ -13,6 +13,6 @@ describe("Subtitle Tools", () => {
await screen.findByRole("heading", { name: "Subtitle Tools" }), await screen.findByRole("heading", { name: "Subtitle Tools" }),
).toBeVisible(); ).toBeVisible();
expect(await screen.findByText("No network requests")).toBeVisible(); expect(await screen.findByText("No network requests")).toBeVisible();
expect(screen.getByRole("tab", { name: "Timing" })).toBeVisible(); expect(screen.getByRole("button", { name: "Timing" })).toBeVisible();
}); });
}); });
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { extractPeaks } from "../../src/media/peaks";
describe("waveform peak extraction", () => {
it("covers every sample when bucket sizes are uneven", () => {
const peaks = extractPeaks(Float32Array.of(0.1, -0.8, 0.2, 0.4, -1), 2);
expect(peaks[0]).toBeCloseTo(0.8);
expect(peaks[1]).toBe(1);
});
it("rejects invalid bounds and handles empty PCM", () => {
expect(extractPeaks(new Float32Array(), 5)).toHaveLength(0);
expect(() => extractPeaks(Float32Array.of(1), 0)).toThrow(/positive/u);
});
});
+47
View File
@@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest";
import { import {
compareDocuments, compareDocuments,
convertFrameRate, convertFrameRate,
insertCue,
mergeCueWithNext,
parseSubtitle, parseSubtitle,
removeCue,
serializeSubtitle, serializeSubtitle,
snapCuesToFrames,
splitCue,
transformCueText,
transformTiming, transformTiming,
validateDocument, validateDocument,
} from "../../src/subtitle/model"; } from "../../src/subtitle/model";
@@ -85,6 +91,47 @@ describe("subtitle model", () => {
]); ]);
}); });
it("imports and exports a bounded flat TTML profile", () => {
const source = `<?xml version="1.0"?><tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:frameRate="25"><body><div><p xml:id="one" begin="00:00:01.000" end="00:00:02.500" style="caption">Hello<br/>world</p><p begin="75f" dur="1s">Frames</p></div></body></tt>`;
const document = parseSubtitle(source);
expect(document.format).toBe("ttml");
expect(document.cues).toMatchObject([
{ id: "one", startMs: 1000, endMs: 2500, text: "Hello\nworld" },
{ startMs: 3000, endMs: 4000, text: "Frames" },
]);
const exported = serializeSubtitle(document, "ttml");
expect(exported).toContain('xml:id="one"');
expect(exported).toContain("Hello<br/>world");
expect(parseSubtitle(exported).cues).toHaveLength(2);
expect(() =>
parseSubtitle(
'<!DOCTYPE tt [<!ENTITY x SYSTEM "file:///etc/passwd">]><tt/>',
"ttml",
),
).toThrow(/entities/iu);
});
it("edits cue structure, text and frame timing without mutating input", () => {
const original = parseSubtitle(SRT);
const inserted = insertCue(original, 1, {
startMs: 2600,
endMs: 3000,
text: " New ",
});
expect(original.cues).toHaveLength(2);
expect(inserted.cues).toHaveLength(3);
const cleaned = transformCueText(inserted, "trim", [1]);
expect(cleaned.cues[1]?.text).toBe("New");
const split = splitCue(cleaned, 0, 1500);
expect(split.cues).toHaveLength(4);
const merged = mergeCueWithNext(split, 0);
expect(merged.cues).toHaveLength(3);
const removed = removeCue(merged, 1);
expect(removed.cues).toHaveLength(2);
const snapped = snapCuesToFrames(removed, 25);
expect(snapped.cues.every((cue) => cue.startMs % 40 === 0)).toBe(true);
});
it("rejects pathological input bounds", () => { it("rejects pathological input bounds", () => {
expect(() => parseSubtitle("x".repeat(4_000_001))).toThrow(/4,000,000/u); expect(() => parseSubtitle("x".repeat(4_000_001))).toThrow(/4,000,000/u);
expect(() => expect(() =>
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client", "node"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "Bundler", "moduleResolution": "Bundler",