Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c2bcd0ff |
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,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
@@ -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,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
|
||||||
|
|||||||
Generated
+22
-23
@@ -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
@@ -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
@@ -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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+269
-34
@@ -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 (!isCurrent(job)) return;
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(decoded.duration) ||
|
!Number.isFinite(decoded.duration) ||
|
||||||
|
decoded.duration <= 0 ||
|
||||||
decoded.duration > MAX_DURATION_SECONDS
|
decoded.duration > MAX_DURATION_SECONDS
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Decoded audio exceeds the four-hour waveform limit.",
|
"Decoded audio must have a positive duration within the four-hour waveform limit.",
|
||||||
);
|
);
|
||||||
const channel = decoded.getChannelData(0);
|
const peaks = await calculatePeaks(decoded.getChannelData(0), job);
|
||||||
const peaks = new Float32Array(Math.min(PEAK_COUNT, channel.length));
|
if (!isCurrent(job)) return;
|
||||||
const stride = Math.max(1, Math.floor(channel.length / peaks.length));
|
setWaveform({ mediaId: id, duration: decoded.duration, peaks });
|
||||||
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();
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
if (isCurrent(job))
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error ? reason.message : "Audio decode failed.",
|
reason instanceof Error ? reason.message : "Audio decode failed.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
void job.context?.close().catch(() => undefined);
|
||||||
|
job.context = undefined;
|
||||||
|
if (isCurrent(job)) {
|
||||||
|
jobRef.current = undefined;
|
||||||
setBusy(false);
|
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>
|
||||||
|
<div className="button-row">
|
||||||
<label className="file-button">
|
<label className="file-button">
|
||||||
{busy ? "Decoding…" : "Open audio"}
|
{media ? "Replace media" : "Open media"}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/*,video/*"
|
accept="audio/*,video/*"
|
||||||
disabled={busy}
|
|
||||||
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 ? (
|
||||||
|
<p className="muted">
|
||||||
|
Showing the previous valid waveform until the new one is ready.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<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
|
<canvas
|
||||||
ref={canvas}
|
ref={canvas}
|
||||||
className="waveform"
|
className="waveform"
|
||||||
|
style={{ width: `${zoom * 100}%` }}
|
||||||
aria-label="Audio waveform with subtitle start markers"
|
aria-label="Audio waveform with subtitle start markers"
|
||||||
|
aria-disabled={!waveformMatchesMedia}
|
||||||
|
tabIndex={waveformMatchesMedia ? 0 : -1}
|
||||||
onClick={seek}
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div className="button-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDocument(sortCues(document))}
|
onClick={() => setDocument(sortCues(document))}
|
||||||
>
|
>
|
||||||
Sort by start
|
Sort by start
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
+340
-1
@@ -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,6 +426,8 @@ export function parseSubtitle(
|
|||||||
? parseVtt(normalized)
|
? parseVtt(normalized)
|
||||||
: format === "ass"
|
: format === "ass"
|
||||||
? parseAss(normalized)
|
? parseAss(normalized)
|
||||||
|
: format === "ttml"
|
||||||
|
? parseTtml(normalized)
|
||||||
: parseSrt(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.");
|
||||||
@@ -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,9 +959,25 @@ export function serializeSubtitle(
|
|||||||
? serializeVtt(document)
|
? serializeVtt(document)
|
||||||
: format === "ass"
|
: format === "ass"
|
||||||
? serializeAss(document)
|
? serializeAss(document)
|
||||||
|
: format === "ttml"
|
||||||
|
? serializeTtml(document)
|
||||||
: serializeSrt(document);
|
: serializeSrt(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function xml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
kind: "added" | "removed" | "timing" | "text" | "timing-and-text";
|
kind: "added" | "removed" | "timing" | "text" | "timing-and-text";
|
||||||
index: number;
|
index: number;
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user