Asked for directly, right after the client-side <track> redesign closed
every *text* subtitle case: PGS/VobSub/DVB subtitles (Blu-ray/DVD rips)
are bitmaps, not text - there's nothing for Emby's Stream.vtt conversion
to extract, so a native <track> fundamentally can't render one. Rather
than keep asking Emby's own transcode to burn these in (the exact thing
that kept failing in earlier sessions - missing fontconfig, then an
unrelated "Error starting ffmpeg"), this runs ffmpeg ourselves: a PGS
stream decodes like a second video track, and ffmpeg's `overlay` filter
composites it directly - no text rendering, no libass, no fontconfig
anywhere in this path.
One shared stream per PARTY, not per viewer - explicitly asked for after
the initial plan: every member plugs into the exact same server-emitted
stream, mirroring "host drives, everyone follows" rather than a
per-viewer model. Picking a burn-in subtitle is therefore a host-only,
party-wide choice, like play/pause/next already are.
- server/src/services/TranscodeService.ts (new): one ffmpeg session per
partyId. getOrStartSession negotiates PlaybackInfo (via the new
CatalogService.getMediaSource), converts Emby's absolute
MediaStreams[].Index into ffmpeg's type-relative 0:s:N/0🅰️N
(absoluteToRelativeIndex - pure, unit-tested), and spawns ffmpeg
against Emby's own direct/static file endpoint (TODO(verify) - no
Emby server reachable from this dev environment, see the function's
own comment). Output is real progressive HLS to a temp dir, served by
two new routes that poll briefly for the next file. endSession/
reapIdleSessions handle teardown; PartyService's destroy path and
advanceQueue both end a party's session immediately, the idle sweep
is the backstop.
- server/src/services/PartyService.ts: Party/PartyPublicState gain
burnInSubtitleIndex/burnInAudioIndex (null together = no burn-in);
advanceQueue clears them (a new item's indices mean nothing on the
old one).
- server/src/realtime/SocketGateway.ts: new host-only "setBurnInSubtitle"
handler broadcasting "burnInChanged" - same shape as "next"/
"itemChanged".
- server/src/services/CatalogService.ts: new getMediaSource() (id/
container/streams together - what TranscodeService needs); extended
the local PlaybackInfoResponse type with Container.
- server/src/Logger.ts: new Logger.transcode().
- server/src/index.ts: GET /api/catalog/burn/:partyId/master.m3u8 and
.../:segment (membership-checked, same pattern as the party REST
routes); a dedicated idle-reap interval for TranscodeService.
- server/Dockerfile: `apk add ffmpeg` in the runtime stage - no font
packages needed, unlike Emby's own text-subtitle burn-in.
- client/src/api/party.ts, client/src/api/embyApi.ts: setBurnInSubtitle/
onBurnInChanged on PartyConnection, burnInStreamUrl().
- client/src/views/player/Player.{html,css,ts}: the subtitle picker's
image-track options go from unconditionally disabled to host-only
selectable, labeled to say picking one restarts the stream for
everyone. Every member (host included) only ever reacts to the
resulting "burnInChanged" broadcast - never applies a pick
optimistically, same discipline the rest of the player already uses.
Audio/quality are disabled while a burn-in is active (one fixed
encode); a follower's subtitle select is disabled too (nothing left
for them to meaningfully pick).
- server/test/transcodeService.test.ts (new): absoluteToRelativeIndex,
session start/reuse/replace, endSession, reapIdleSessions -
child_process.spawn mocked via a small explicit test seam
(setSpawnForTesting), never invoking a real ffmpeg binary.
- server/test/socketGateway.test.ts, partyService.test.ts,
catalogService.test.ts: host-only gating + broadcast for
setBurnInSubtitle, advanceQueue's reset, toPublicState exposing the
new fields, getMediaSource.
- docs/architecture.md, README.md: design + an explicit "assumptions to
verify" note (the direct-source URL shape, first and foremost).
Verified: full typecheck+test+build on both projects (56/56 server
tests). No Emby server or ffmpeg binary is reachable from this dev
environment, so the actual command against a real PGS file is
unverified - flagged explicitly in the docs, same "TODO(verify)" spirit
already used elsewhere in this codebase for an Emby response shape that
can't be checked locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
51 lines
1.7 KiB
Docker
51 lines
1.7 KiB
Docker
# Build context must be the repo root (not server/), since this also needs
|
|
# the client/ folder: docker build -f server/Dockerfile -t watchparty .
|
|
#
|
|
# Two stages: the first compiles both TypeScript projects (server/src -> JS
|
|
# via tsc, client/src -> a bundled JS+CSS via esbuild - see
|
|
# docs/architecture.md's TypeScript-migration section for why two separate
|
|
# toolchains rather than one shared build), the second is the actual
|
|
# runtime image, holding only the compiled output plus production
|
|
# dependencies - none of the TypeScript/esbuild toolchain itself ships.
|
|
|
|
FROM node:20-alpine AS build
|
|
|
|
WORKDIR /app
|
|
|
|
COPY server/package.json server/package-lock.json* ./server/
|
|
RUN cd server && npm install
|
|
COPY server/tsconfig.json ./server/
|
|
COPY server/src ./server/src
|
|
RUN cd server && npm run build
|
|
|
|
COPY client/package.json client/package-lock.json* ./client/
|
|
RUN cd client && npm install
|
|
COPY client/build.mjs ./client/
|
|
COPY client/tsconfig.json ./client/
|
|
COPY client/src ./client/src
|
|
RUN cd client && npm run build
|
|
|
|
|
|
FROM node:20-alpine
|
|
|
|
# ffmpeg - TranscodeService spawns it directly (see server/src/services/
|
|
# TranscodeService.ts) to burn PGS/image subtitles into video ourselves,
|
|
# the one gap Emby's own transcode + this app's client-side <track>
|
|
# rendering can't close between them. Unlike Emby's own text-subtitle
|
|
# burn-in, this needs no fontconfig/font packages at all - a PGS overlay
|
|
# is bitmap compositing, not text rendering.
|
|
RUN apk add --no-cache ffmpeg
|
|
|
|
WORKDIR /app
|
|
|
|
COPY server/package.json server/package-lock.json* ./server/
|
|
RUN cd server && npm install --omit=dev
|
|
|
|
COPY --from=build /app/server/dist ./server/dist
|
|
COPY --from=build /app/client/dist ./client/dist
|
|
|
|
WORKDIR /app/server
|
|
ENV PORT=3000
|
|
EXPOSE 3000
|
|
|
|
CMD ["node", "dist/index.js"]
|