Skip to content
dsh.fish
Bundle

subtitle-studio

Multi-language subtitle translation workflow for dsh: SRT/VTT parsing, sentence-level LLM translation, bilingual merge, alignment validation, and batch processing.

Source
JohnXu22786
stars
1 stars
License
MIT
Updated
Updated 5 days ago

Readme

# subtitle-studio

A self-contained multilingual subtitle translation workflow plugin for **dsh**
(DeepSeek Harness), built around the "everything is a plugin" idea. It parses
and writes SRT/VTT subtitles, translates them sentence-by-sentence through a
configurable LLM, merges bilingual outputs, validates alignment, and processes
whole directories — exposed both as dsh tools (`ctx.tools`) and as a
dependency-free CLI.

> `subtitle-studio` is a new, self-contained implementation. Its subtitle
> parsers are written from scratch (no heavy subtitle libraries), its LLM layer
> speaks OpenAI-compatible HTTP out of the box (DeepSeek by default) and can
> plug into the harness's `ctx.llm` seam, and everything is UTF-8 on both ends.

---

## Table of contents

- [Features](#features)
- [Installation](#installation)
- [Using subtitle-studio as a dsh bundle](#using-subtitle-studio-as-a-dsh-bundle)
- [CLI](#cli)
- [Configuration](#configuration)
- [Glossary format](#glossary-format)
- [Library & service API](#library--service-api)
- [Testing](#testing)
- [Limitations](#limitations)
- [License](#license)

## Features

1. **Parsing & writing** — SRT and WebVTT with a tolerant state machine:
   - accepts missing sequence numbers, missing blank separators, CRLF, and a UTF
     BOM (UTF-8 / UTF-16LE / UTF-16BE);
   - keeps multi-line cue text and VTT identifiers/settings verbatim;
   - skips junk lines and malformed timecode lines, reporting them as issues
     instead of aborting;
   - output is always UTF-8 and round-trips preserve timing to the millisecond.

2. **Sentence-by-sentence translation** — batched requests over a character
   budget, JSON payloads keyed by cue index for reliable alignment, bounded
   retries (with a corrective nudge on malformed JSON), timeouts, and glossary
   injection. Works with any OpenAI-compatible endpoint (DeepSeek is default)
   or the dsh `ctx.llm` seam.

3. **Bilingual subtitles** — merge original + translation either *stacked*
   (translation lines under the original) or *interleaved* (alternating cues),
   with optional per-line tags and a separator. The timeline is copied verbatim
   from the source, so it is preserved by construction.

4. **Alignment validation** — checks translation count vs. source, missing and
   extra cues, empty translations, overlong cues that should be split, and
   timeline overlaps.

5. **Batch processing** — translate whole directories with a bounded concurrency
   pool, per-file retry with backoff, an atomic checkpoint file for pause/resume,
   and token/cost estimation (with approximate per-model rates that can be
   overridden).

6. **Two entry points** — five dsh tools (`sub_parse`, `sub_translate`,
   `sub_merge`, `sub_export`, `sub_glossary`) plus a full CLI.

## Installation

Requirements: Node.js ≥ 18.18.

```bash
# 1. install dev dependencies and build
npm install
npm run build

# 2. run the CLI directly
node bin/subtitle-studio.js --help

# or install the bin globally (makes `subtitle-studio` available)
npm link
```

The package ships **zero runtime dependencies**. TypeScript is only a dev
dependency (build to `lib/` with `tsc`).

## Using subtitle-studio as a dsh bundle

The package is a valid dsh **bundle**: `package.json` declares the bundle
manifest, `cordis.patch.yml` inserts the plugin row into a profile, and
`lib/index.js` exports the Cordis-style entry (`name` + `apply(ctx, config)`).

### 1. Add the bundle to a profile

```bash
dsh plugin --profile <name> add subtitle-studio
```

This installs the package and appends `subtitle-studio` to the profile's
`dsh.profile.bundles`. The profile loader then applies our patch (the
`cordis.patch.yml` shipped by the bundle), which inserts a row:

```yaml
- insert:
    - id: subtitle-studio
      name: subtitle-studio
      config:
        targetLanguages: []
        sourceLanguage: ''
```

### 2. What the plugin registers

- **Tools** on `ctx.tools` (registered when the `tools` service is available):

  | tool           | purpose                                                              |
  | -------------- | -------------------------------------------------------------------- |
  | `sub_parse`    | parse a subtitle file into structured cues (JSON output)             |
  | `sub_translate`| translate a subtitle file into one or more languages (LLM call)      |
  | `sub_merge`    | combine a source subtitle with a translation payload (stacked/interleaved) |
  | `sub_export`   | convert/export a subtitle to SRT or VTT (UTF-8), optionally bilingual |
  | `sub_glossary` | manage the JSON terminology glossary (list/add/remove/merge)         |

- **Service** `subtitleStudio` (when `ctx.provide` exists) exposing
  `parse`, `translate`, `merge`, `validate`, `glossary`, `cost`, `stringify`,
  `convert` — a convenient library surface for other plugins.

### 3. Plugin configuration

The plugin reads the `config` object of its patch row (and any profile
override). Keys:

| key                     | type            | default                         | meaning                                    |
| ----------------------- | --------------- | ------------------------------- | ------------------------------------------ |
| `llm.provider`          | `"openai"\|"dsh"` | `"openai"`                    | HTTP backend vs. harness `ctx.llm` seam    |
| `llm.baseUrl`           | string          | `https://api.deepseek.com/v1`   | OpenAI-compatible endpoint                 |
| `llm.apiKey`            | string          | —                               | API key; supports `${ENV_VAR}` expansion   |
| `llm.model`             | string          | `deepseek-chat`                 | model name                                 |
| `llm.timeoutMs`         | number          | `120000`                        | per-request timeout                        |
| `llm.maxRetries`        | number          | `2`                             | retries on transient failures              |
| `llm.jsonMode`          | boolean         | `true`                          | request a JSON object response             |
| `llm.chunkChars`        | number          | `3500`                          | cue-character budget per request           |
| `sourceLanguage`        | string          | `""` (auto)                     | source language tag                        |
| `targetLanguages`       | string[]        | `[]`                            | target languages for translation           |
| `glossary.paths`        | string[]        | `[]`                            | glossary JSON files (merged)               |
| `output.layout`         | `stacked\|interleaved` | `stacked`              | bilingual merge layout                     |
| `output.separator`      | string          | `""`                            | line between original & translated blocks  |
| `output.tagTarget`      | string          | —                               | per-line tag prefix for translated lines   |
| `output.format`         | `srt\|vtt`      | keep source                     | output container format                    |
| `output.utf8Bom`        | boolean         | `false`                         | write a UTF-8 BOM                          |
| `batch.concurrency`     | number          | `2`                             | parallel files during batch                |
| `batch.maxRetries`      | number          | `2`                             | per-file retries                           |
| `batch.checkpoint`      | string          | `subtitle-studio.checkpoint.json` | checkpoint path                          |
| `validation.maxChars`   | number          | `160`                           | CJK over-long cue threshold                |
| `validation.maxWords`   | number          | `40`                            | Latin over-long cue threshold              |

Example patch override (in the profile's own `cordis.patch.yml`, whole-row
config replacement — see the dsh docs on patch layer semantics):

```yaml
- id: subtitle-studio
  config:
    llm:
      provider: openai
      baseUrl: https://api.deepseek.com/v1
      apiKey: ${DEEPSEEK_API_KEY}
      model: deepseek-chat
    sourceLanguage: en
    targetLanguages: [zh, ja]
    output:
      layout: stacked
```

> To use the harness's own LLM provider (e.g. the DeepSeek adapter configured in
> the harness), set `llm.provider: dsh`. The plugin then calls `ctx.llm.stream`.

### 4. Notes for harness authors

- The entry never imports `@deepseek-ai/cordis`; the context is consumed
  structurally, so the same source compiles with or without the harness
  installed. Type-augmentation fans can add
  `declare module '@deepseek-ai/cordis' { interface Context { subtitleStudio: ... } }`
  themselves.
- Tools are registered through `ctx.inject(['tools'], sub => …)` so the plugin
  starts even in a minimal profile; nothing hard-requires `ctx.llm` unless
  `provider: dsh` is set.

## CLI

```
subtitle-studio <command> [options]
```

All commands accept `--config <file.json>` to load the plugin-shaped config,
plus per-command flags (which override config).

### parse

```bash
subtitle-studio parse movie.srt
subtitle-studio parse movie.vtt --pretty       # full JSON
```

### translate

```bash
# one target language -> bilingual stacked SRT
subtitle-studio translate movie.srt \
  --target zh --source en \
  --glossary glossary.json \
  --output movie.zh.srt

# multiple targets -> fmt: <stem>.<target>.bilingual.<ext>
subtitle-studio translate movie.vtt --target zh --target fr --output movie.bilingual.vtt

# interleaved layout + shownote line tags
subtitle-studio translate movie.srt --target zh --layout interleaved --tag "[zh] " --output out.srt

# API key from environment
subtitle-studio translate movie.srt --target zh --api-key ${DEEPSEEK_API_KEY} --output out.srt

# persist a mid-run partial checkpoint
subtitle-studio translate movie.srt --target zh --output out.srt --save-partial partial.json
```

Flow: parse → validate timeline → print cost estimate → translate → validate
alignment → merge → write bilingual subtitle + `<name>.translation.json`.

### merge

```bash
# from a translation JSON file
subtitle-studio merge movie.srt translation.json --layout interleaved --output merged.srt

# or inline JSON
subtitle-studio merge movie.srt '{"entries":[{"index":1,"text":"你好"}]}' --layout stacked
```

### export

```bash
# convert srt -> vtt preserving timestamps
subtitle-studio export movie.srt --output movie.vtt

# bilingual export
subtitle-studio export movie.srt --output movie.vtt --translation translation.json --layout interleaved
```

### validate

```bash
subtitle-studio validate movie.srt                    # timeline sanity + overlaps
subtitle-studio validate movie.srt --compare translation.json  # alignment against a translation
subtitle-studio validate movie.srt --overlong         # over-length cue report
subtitle-studio validate movie.srt --no-overlap       # skip overlap checks
```

### glossary

```bash
subtitle-studio glossary list --path glossary.json
subtitle-studio glossary add --path glossary.json --source "DeepSeek Harness" --target "深度求索工具链" --scope zh
subtitle-studio glossary remove --path glossary.json --source "DeepSeek Harness"
subtitle-studio glossary merge --path glossary.json --with other.json
```

### batch

```bash
# dry-run cost estimate
subtitle-studio batch ./movies --output-dir ./out --target zh --estimate

# real run with 3 parallel workers and resume-capable checkpoint
subtitle-studio batch ./movies --output-dir ./out \
  --target zh --glossary glossary.json \
  --concurrency 3 --checkpoint cp.json --layout interleaved

# resume after an interruption (skips done files, retries failed)
subtitle-studio batch ./movies --output-dir ./out --target zh --checkpoint cp.json --resume
```

Batch notes:
- The output directory **mirrors the input tree**, so two files that share a
  basename (e.g. `a/clip.srt` and `b/clip.srt`) never collide
  (`out/a/clip.bilingual.srt`, `out/b/clip.bilingual.srt`).
- The output directory itself is excluded from scanning, so a batch never
  re-translates its own artifacts (`out/x.bilingual.bilingual.srt` can't
  happen).
- `--resume` re-queues files left `processing` by a crash, skips `done` files,
  and (with `--retry-failed`, the default) retries `failed` files with a fresh
  attempt budget. The checkpoint is written atomically after every file.

### cost

```bash
subtitle-studio cost movie.srt --target zh
subtitle-studio cost ./movies --target zh --target fr --model deepseek-chat --rate-in 0.27 --rate-out 1.10
```

## Configuration

The CLI mirrors the dsh plugin configuration (see the table above) through
`--config` or flags. API keys can reference environment variables:

```bash
node bin/subtitle-studio.js translate a.srt --target zh --api-key "${env:DEEPSEEK_API_KEY}"
```

Prices in the cost estimator are approximate list prices keyed by model
(`deepseek-chat`, `deepseek-reasoner`) and are always overridable with
`--rate-in`/`--rate-out` (or `llm` config equivalents).

## Glossary format

A glossary is a JSON document:

```json
{
  "name": "sample-glossary",
  "entries": [
    { "source": "DeepSeek Harness", "target": "深度求索工具链", "scope": "zh", "note": "official product name" },
    { "source": "hello", "target": "bonjour", "scope": "fr" },
    { "source": "bilingual", "target": "双语" }
  ]
}
```

- `source` → `target` map a term to its mandated translation.
- `scope` restricts an entry to one target language; entries without a scope
  apply to every language. This is how one glossary serves multiple targets.
- During translation the applicable entries are injected into the system prompt
  as mandatory terminology. The entry identity is `(source, target, scope)` —
  upserting the same key replaces it, different keys coexist.
- See `examples/glossary.json` and run `subtitle-studio glossary list`.

## Library & service API

The public entry `lib/index.js` re-exports the engine pieces directly as
functions:

```js
import {
  parseSubtitle, stringifySubtitle, convertSubtitle, detectFormat,
  translateCues, translateDocument,
  mergeBilingual, mergeWithEntries,
  validateSubtitle, validateTranslationAlignment,
  loadGlossaryFile, mergeGlossaries, buildGlossaryPrompt,
  createLlmClient, estimateCost,
} from 'subtitle-studio'
```

All timestamps are integer milliseconds; all file I/O is UTF-8.

## Testing

```bash
npm test
```

Runs `tsc` then the Node built-in test runner over `test/*.test.js`
(122 tests covering time parsing, SRT/VTT tolerance, encoding/BOM, merge,
validation, glossary, translation with a mocked HTTP backend, batch/checkpoint,
and the CLI + dsh tool/plugin surface).

## Limitations

- The token counter is a heuristic; use `--rate-in/--rate-out` for accurate
  billing, and treat cost prints as estimates.
- Interleaved bilingual output intentionally reuses the exact source timings
  for both cues, so the overlap validator flags them — validate the *source*
  document for overlaps, or exclude bilingual docs from overlap checks.
- `jsonMode` requires an endpoint that supports `response_format`; the client
  automatically retries without it if the provider rejects it with HTTP 400.
- The `dsh` LLM provider path assumes the harness `ctx.llm.stream` chunk shape;
  other chunk layouts degrade to generic text extraction.

## License

MIT — see [LICENSE](LICENSE).

Install

dsh plugin --profile web add github:JohnXu22786/subtitle-studio

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source