feat(player): let editors upload subtitles for a version

Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file
always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the
same path: both already play through our own video element, so a track element is all it takes.

Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and
re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through.
That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows
editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused.

A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the
iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the
video had them.

Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the
delivered cut, not a comment attachment.
This commit is contained in:
2026-08-22 07:51:46 +03:00
parent 1f3c6b3f1e
commit d981d98cf5
26 changed files with 2529 additions and 25 deletions
+6
View File
@@ -235,6 +235,12 @@ export type BuildProjectDownloadManifestOptions = {
includeAssets?: boolean;
};
/**
* Subtitle tracks are deliberately not in the manifest. They belong to a version rather
* than to a video, and a zip that carried them would need a naming scheme that pairs each
* .vtt with the cut it was timed against. Add them the day that pairing is designed, not
* as a loose file next to the videos.
*/
export function buildProjectDownloadManifest(
projectName: string,
videos: VideoRow[],
+26 -4
View File
@@ -3,6 +3,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db';
import { runWithConcurrency } from '@/lib/async-pool';
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
import { logError } from '@/lib/logger';
/** The path prefix for images served by the upload API. */
@@ -34,7 +35,7 @@ export function mediaUrlToKey(url: string): string | null {
return filename ? `images/${filename}` : null;
}
return videoProxyPathToObjectKey(url);
return subtitleProxyPathToObjectKey(url) ?? videoProxyPathToObjectKey(url);
}
/**
@@ -82,7 +83,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const [comments, assets, versions] = await Promise.all([
const [comments, assets, versions, subtitles] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -101,6 +102,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
where: { videoParentId: videoId, providerId: 'r2' },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { videoParentId: videoId } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -114,6 +119,9 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls;
}
@@ -121,7 +129,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const [comments, assets, versions] = await Promise.all([
const [comments, assets, versions, subtitles] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -140,6 +148,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
where: { providerId: 'r2', video: { projectId } },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { video: { projectId } } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls;
}
@@ -160,7 +175,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const [comments, assets, versions] = await Promise.all([
const [comments, assets, versions, subtitles] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -179,6 +194,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
where: { providerId: 'r2', video: { project: { workspaceId } } },
select: { originalUrl: true, thumbnailUrl: true },
}),
db.videoSubtitle.findMany({
where: { version: { video: { project: { workspaceId } } } },
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach((c) => {
@@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
});
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls;
}
+1 -1
View File
@@ -24,7 +24,7 @@ type ProxyR2MediaOptions = {
// call sites gate the file name on a strict pattern first, and a fourth that forgot would
// otherwise hand a traversal straight to GetObject.
const SAFE_MEDIA_OBJECT_KEY =
/^(?:images|voice|videos)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
/^(?:images|voice|videos|subtitles)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export function isSafeR2MediaKey(key: string): boolean {
return SAFE_MEDIA_OBJECT_KEY.test(key);
+3
View File
@@ -71,6 +71,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'subtitle-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
'subtitle-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
'subtitle-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
// Search — debounced on client but protect against scripted callers
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
+25 -8
View File
@@ -132,6 +132,8 @@ export const UPLOAD_RESERVATION_PURPOSES = {
R2_VIDEO: 'R2_VIDEO',
/** A direct upload to Bunny, where the bytes never pass through us. */
BUNNY: 'BUNNY',
/** A subtitle track, which lands in our own S3-compatible storage whatever hosts the video. */
SUBTITLE: 'SUBTITLE',
} as const;
export type UploadReservationPurpose =
@@ -147,14 +149,15 @@ class QuotaExceededError extends Error {}
* every upload.
*/
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
const [r2AssetRows, r2VideoRows, subtitleRows, bunnyUserBytes, reservationRows] =
await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets
WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
`,
db.$queryRaw<[{ total: bigint }]>`
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId"
@@ -163,21 +166,27 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`,
getUserBunnyStorageBytes(userId),
db.$queryRaw<[{ total: bigint }]>`
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_subtitles
WHERE "billedUserId" = ${userId}
`,
getUserBunnyStorageBytes(userId),
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
FROM upload_reservations
WHERE "billedUserId" = ${userId}
AND "expiresAt" > NOW()
`,
]);
]);
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
const subtitleBytes = subtitleRows[0]?.total ?? BigInt(0);
const bunnyBytes = BigInt(bunnyUserBytes);
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
return r2AssetBytes + r2VideoBytes + subtitleBytes + bunnyBytes + reservedBytes;
}
/**
@@ -291,7 +300,15 @@ export async function reserveStorageQuota(
WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2'
`;
const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0));
const [subtitleRow] = await tx.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_subtitles
WHERE "billedUserId" = ${userId}
`;
const r2Bytes =
(r2AssetRow?.total ?? BigInt(0)) +
(r2VideoRow?.total ?? BigInt(0)) +
(subtitleRow?.total ?? BigInt(0));
// Read active (non-expired) reservations under the same lock
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
+272
View File
@@ -0,0 +1,272 @@
/**
* Subtitle uploads are normalised before they are stored: whatever the user hands us,
* SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file.
* Anything we did not understand is dropped rather than passed through, so the file the
* player fetches contains cues and nothing else. That is what makes it safe to serve a
* user-supplied text file from our own origin.
*/
/** Uploaded subtitle files are text. Two megabytes is a feature-length film with room to spare. */
export const MAX_SUBTITLE_FILE_SIZE = 2 * 1024 * 1024;
/** Ceiling on the normalised output, so a pathological input cannot be stored. */
export const MAX_NORMALIZED_SUBTITLE_SIZE = 1024 * 1024;
export const MAX_SUBTITLE_CUES = 5000;
/** Longer than this and it is not a subtitle, it is a document being smuggled in. */
const MAX_CUE_TEXT_LENGTH = 500;
export const ALLOWED_SUBTITLE_EXTENSIONS = ['vtt', 'srt'] as const;
export const SUBTITLE_OBJECT_KEY_PREFIX = 'subtitles/';
export const SUBTITLE_PROXY_PREFIX = '/api/upload/subtitle/';
/** The only shape a subtitle URL may take once it has been through our upload API. */
export const SAFE_SUBTITLE_PROXY_PATH =
/^\/api\/upload\/subtitle\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
export const SAFE_SUBTITLE_FILENAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
export const SUBTITLE_CONTENT_TYPE = 'text/vtt; charset=utf-8';
/** Room for a label a human typed, not for a paragraph. */
const MAX_SUBTITLE_LABEL_LENGTH = 60;
/**
* BCP-47, narrowed: a primary subtag plus optional subtags. Wide enough for `tr`,
* `en-US` and `zh-Hant-TW`, narrow enough that the value is safe in an HTML attribute
* and in a unique index.
*/
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,3}$/i;
export type SubtitleCue = {
/** Seconds from the start of the video. */
start: number;
end: number;
text: string;
};
export type SubtitleNormalizeResult =
| { ok: true; vtt: string; cueCount: number }
| { ok: false; error: string };
/**
* Cue text may carry a small amount of WebVTT markup. Everything outside this list is
* removed: the browser's VTT parser does not execute scripts, but a file that only ever
* contains tags we recognise is one less thing to reason about.
*/
const ALLOWED_CUE_TAGS = [
/^<\/?[biu]>$/i,
/^<\/?ruby>$/i,
/^<\/?rt>$/i,
/^<\/?c(?:\.[\w-]+)*>$/i,
/^<v(?:\.[\w-]+)*(?:\s+[^<>]{1,80})?>$/i,
/^<\/v>$/i,
/^<\d{1,3}:\d{2}(?::\d{2})?\.\d{3}>$/,
];
export function getSubtitleExtension(fileName: string): 'vtt' | 'srt' | null {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext === 'vtt' || ext === 'srt') return ext;
return null;
}
/**
* Normalise a language tag for storage. Kept lowercase so the unique index on
* (version, language) treats `TR` and `tr` as the same track.
*/
export function normalizeSubtitleLanguage(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || !LANGUAGE_TAG.test(trimmed)) return null;
return trimmed.toLowerCase();
}
export function sanitizeSubtitleLabel(value: unknown, fallback: string): string {
const raw = typeof value === 'string' ? value : '';
const normalized = raw
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return fallback;
return normalized.slice(0, MAX_SUBTITLE_LABEL_LENGTH);
}
/**
* Subtitle files written by desktop editors are routinely not UTF-8. A Turkish SRT saved
* out of a Windows tool is usually windows-1254, and rejecting it outright would send the
* user off to convert a file we can decode ourselves. UTF-8 is tried strictly first so a
* valid file is never mangled by a legacy codepage.
*/
export function decodeSubtitleBuffer(buffer: Uint8Array): string | null {
for (const encoding of ['utf-8', 'windows-1254', 'windows-1252']) {
try {
const decoded = new TextDecoder(encoding, { fatal: true }).decode(buffer);
return decoded.replace(/^\uFEFF/, '');
} catch {
// Wrong encoding, or one this runtime's ICU build does not carry. Try the next.
}
}
return null;
}
function parseTimestamp(value: string): number | null {
const match = /^(?:(\d{1,3}):)?([0-5]?\d):([0-5]?\d)[.,](\d{1,3})$/.exec(value.trim());
if (!match) return null;
const hours = match[1] ? Number(match[1]) : 0;
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millis = Number(match[4].padEnd(3, '0'));
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
}
function formatTimestamp(seconds: number): string {
const clamped = Math.max(0, seconds);
const totalMillis = Math.round(clamped * 1000);
const hours = Math.floor(totalMillis / 3_600_000);
const minutes = Math.floor((totalMillis % 3_600_000) / 60_000);
const secs = Math.floor((totalMillis % 60_000) / 1000);
const millis = totalMillis % 1000;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
}
function parseTimingLine(line: string): { start: number; end: number } | null {
const separatorIndex = line.indexOf('-->');
if (separatorIndex === -1) return null;
const start = parseTimestamp(line.slice(0, separatorIndex));
// Anything after the end timestamp is a cue setting (position, align, line). They are
// dropped: the player positions cues itself so its own control bar does not cover them.
const rest = line.slice(separatorIndex + 3).trim();
const end = parseTimestamp(rest.split(/\s+/)[0] ?? '');
if (start === null || end === null) return null;
return { start, end };
}
function sanitizeCueLine(line: string): string {
return (
line
// ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT
// parser renders them as literal text, which is never what the author meant.
.replace(/\{\\[^}]*\}/g, '')
.replace(/<[^<>]*>/g, (tag) => (ALLOWED_CUE_TAGS.some((re) => re.test(tag)) ? tag : ''))
// A cue text line containing an arrow would be read back as a timing line and split
// the cue in two. The entity is what the WebVTT parser expects for a literal `>`.
.replace(/-->/g, '--&gt;')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
.trimEnd()
);
}
/**
* Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers,
* SRT sequence numbers) are skipped rather than carried over.
*/
export function parseSubtitleCues(input: string): SubtitleCue[] {
const lines = input.replace(/\r\n?/g, '\n').split('\n');
const cues: SubtitleCue[] = [];
let index = 0;
// A STYLE or REGION block runs until the next blank line and may itself contain no
// timing, so it is skipped wholesale rather than line by line.
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) {
index += 1;
while (index < lines.length && lines[index].trim()) index += 1;
continue;
}
// A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue
// id). The timing is then on the following line.
let timing = parseTimingLine(trimmed);
if (!timing) {
const next = lines[index + 1]?.trim();
if (!next) {
index += 1;
continue;
}
timing = parseTimingLine(next);
if (!timing) {
index += 1;
continue;
}
index += 1;
}
index += 1;
const textLines: string[] = [];
while (index < lines.length && lines[index].trim()) {
const sanitized = sanitizeCueLine(lines[index]);
if (sanitized.trim()) textLines.push(sanitized);
index += 1;
}
if (timing.end <= timing.start) continue;
const text = textLines.join('\n').slice(0, MAX_CUE_TEXT_LENGTH).trim();
if (!text) continue;
cues.push({ start: timing.start, end: timing.end, text });
if (cues.length >= MAX_SUBTITLE_CUES) break;
}
return cues;
}
export function serializeWebVtt(cues: SubtitleCue[]): string {
const body = cues
.map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`)
.join('\n\n');
return `WEBVTT\n\n${body}\n`;
}
/**
* The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining
* what is wrong with the file in terms the person who uploaded it can act on.
*/
export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult {
if (buffer.byteLength === 0) {
return { ok: false, error: 'Subtitle file is empty' };
}
const decoded = decodeSubtitleBuffer(buffer);
if (decoded === null) {
return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' };
}
const cues = parseSubtitleCues(decoded);
if (cues.length === 0) {
return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' };
}
const vtt = serializeWebVtt(cues);
if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) {
return { ok: false, error: 'Subtitle file is too large after conversion' };
}
return { ok: true, vtt, cueCount: cues.length };
}
export function subtitleFileNameToProxyUrl(fileName: string): string {
return `${SUBTITLE_PROXY_PREFIX}${fileName}`;
}
export function extractSubtitleFileNameFromProxyUrl(url: string): string | null {
if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null;
return url.slice(SUBTITLE_PROXY_PREFIX.length) || null;
}
export function subtitleProxyPathToObjectKey(url: string): string | null {
const fileName = extractSubtitleFileNameFromProxyUrl(url);
if (!fileName) return null;
return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
}