Merge pull request #68 from yusufipk/feat/version-subtitles

feat(player): let editors upload subtitles for a version
This commit is contained in:
Yusuf İpek
2026-08-22 08:16:35 +03:00
committed by GitHub
26 changed files with 2565 additions and 25 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
- Timestamped comments directly on the video timeline - Timestamped comments directly on the video timeline
- Voice notes, image attachments, and frame annotations - Voice notes, image attachments, and frame annotations
- Version history with side-by-side compare - Version history with side-by-side compare and per-version subtitle tracks
- Approval requests and sign-off tracking - Approval requests and sign-off tracking
- Share links for client review with optional guest commenting - Share links for client review with optional guest commenting
- Workspaces, projects, member roles, and invitation flows - Workspaces, projects, member roles, and invitation flows
@@ -41,7 +41,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
### Versioning And Comparison ### Versioning And Comparison
- Videos support multiple versions inside the same review thread. - Videos support multiple versions inside the same review thread, each with its own subtitle tracks uploaded as SRT or WebVTT.
- Teams can switch between versions without losing review context. - Teams can switch between versions without losing review context.
- Compare mode lets reviewers inspect two versions side by side. - Compare mode lets reviewers inspect two versions side by side.
@@ -130,6 +130,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
videoId: result.version.videoId, videoId: result.version.videoId,
}; };
// Read before the delete: the rows cascade away with the version, and their stored
// objects would then have nothing pointing at them. Subtitles live in our own storage
// whatever hosts the video, so this runs for a Bunny-hosted cut too.
const subtitles = await db.videoSubtitle.findMany({
where: { versionId },
select: { sourceUrl: true },
});
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
// Delete the version (cascades to comments). // Delete the version (cascades to comments).
await tx.videoVersion.delete({ where: { id: versionId } }); await tx.videoVersion.delete({ where: { id: versionId } });
@@ -149,15 +157,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
} }
}); });
const versionMediaUrls = [
...subtitles.map((subtitle) => subtitle.sourceUrl),
...(result.version.providerId === 'r2'
? [result.version.originalUrl, result.version.thumbnailUrl]
: []),
].filter((url): url is string => Boolean(url));
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([ const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
cleanupBunnyStreamVideosBestEffort([bunnyRef]), cleanupBunnyStreamVideosBestEffort([bunnyRef]),
result.version.providerId === 'r2' deleteMediaFilesBestEffort(versionMediaUrls),
? deleteMediaFilesBestEffort(
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
Boolean(url)
)
)
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
]); ]);
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult }; const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
const cleanupWarnings = buildCleanupWarnings(cleanupInput); const cleanupWarnings = buildCleanupWarnings(cleanupInput);
@@ -0,0 +1,91 @@
import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { apiErrors } from '@/lib/api-response';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { logError } from '@/lib/logger';
import {
SAFE_SUBTITLE_FILENAME,
SUBTITLE_CONTENT_TYPE,
SUBTITLE_OBJECT_KEY_PREFIX,
subtitleFileNameToProxyUrl,
} from '@/lib/subtitle-validation';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename to prevent path traversal
if (!SAFE_SUBTITLE_FILENAME.test(filename)) {
return apiErrors.badRequest('Invalid filename');
}
// Parallelize the DB lookup and session check to narrow the timing delta
// between "subtitle not found" and "subtitle found, access denied" responses.
const [subtitle, session] = await Promise.all([
db.videoSubtitle.findUnique({
where: { sourceUrl: subtitleFileNameToProxyUrl(filename) },
select: {
version: {
select: {
video: {
select: {
id: true,
projectId: true,
project: {
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
},
},
},
},
},
},
}),
auth(),
]);
const video = subtitle?.version?.video ?? null;
if (!video) {
return apiErrors.forbidden('Access denied');
}
const access = await checkProjectAccess(video.project, session?.user?.id);
if (!access.hasAccess) {
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: null;
if (!shareAccess?.hasAccess) {
return apiErrors.forbidden('Access denied');
}
}
return proxyR2MediaObject({
request,
key: `${SUBTITLE_OBJECT_KEY_PREFIX}${filename}`,
fallbackContentType: SUBTITLE_CONTENT_TYPE,
cacheControl: 'private, no-store',
extraHeaders: {
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
},
internalErrorMessage: 'Failed to retrieve subtitle',
});
} catch (error: unknown) {
logError('Error serving subtitle:', error);
return apiErrors.internalError('Failed to retrieve subtitle');
}
}
@@ -0,0 +1,48 @@
import { NextRequest } from 'next/server';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { rateLimit } from '@/lib/rate-limit';
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; subtitleId: string }> };
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'subtitle-delete');
if (limited) return limited;
const { videoId, subtitleId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.viewerUserId || !context.canManageAssets) {
return apiErrors.forbidden('Access denied');
}
const subtitle = await db.videoSubtitle.findFirst({
where: { id: subtitleId, version: { videoParentId: videoId } },
select: { id: true, sourceUrl: true },
});
if (!subtitle) return apiErrors.notFound('Subtitle');
// Storage first, row second, for the same reason video deletion does it in that
// order: a refused delete leaves the row in place so the operation can be retried,
// rather than orphaning an object nothing points at any more.
const objectKey = subtitleProxyPathToObjectKey(subtitle.sourceUrl);
if (objectKey) {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: objectKey }));
}
await db.videoSubtitle.delete({ where: { id: subtitle.id } });
const response = successResponse({ id: subtitle.id, deleted: true });
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error deleting subtitle:', error);
return apiErrors.internalError('Failed to delete subtitle');
}
}
+276
View File
@@ -0,0 +1,276 @@
import { NextRequest } from 'next/server';
import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { rateLimit } from '@/lib/rate-limit';
import {
releaseStorageReservation,
reserveStorageQuota,
UPLOAD_RESERVATION_PURPOSES,
} from '@/lib/storage-quota';
import {
getSubtitleExtension,
MAX_SUBTITLE_FILE_SIZE,
normalizeSubtitleFile,
normalizeSubtitleLanguage,
sanitizeSubtitleLabel,
subtitleFileNameToProxyUrl,
SUBTITLE_CONTENT_TYPE,
SUBTITLE_OBJECT_KEY_PREFIX,
subtitleProxyPathToObjectKey,
} from '@/lib/subtitle-validation';
import { getVideoAssetAccessContext } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string }> };
const MAX_MULTIPART_BODY_SIZE = MAX_SUBTITLE_FILE_SIZE + 64 * 1024;
/** A cut with more tracks than this is not being subtitled, it is being used as storage. */
const MAX_SUBTITLES_PER_VERSION = 20;
type SubtitleRow = {
id: string;
versionId: string;
language: string;
label: string;
sourceUrl: string;
sizeBytes: bigint;
createdAt: Date;
updatedAt: Date;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
};
function shapeSubtitle(subtitle: SubtitleRow, canManage: boolean) {
return {
id: subtitle.id,
versionId: subtitle.versionId,
language: subtitle.language,
label: subtitle.label,
url: subtitle.sourceUrl,
sizeBytes: Number(subtitle.sizeBytes),
createdAt: subtitle.createdAt,
updatedAt: subtitle.updatedAt,
uploadedByUser: subtitle.uploadedByUser,
canDelete: canManage,
};
}
const SUBTITLE_SELECT = {
id: true,
versionId: true,
language: true,
label: true,
sourceUrl: true,
sizeBytes: true,
createdAt: true,
updatedAt: true,
uploadedByUser: { select: { id: true, name: true, image: true } },
} as const;
// GET /api/videos/[videoId]/subtitles?versionId=...
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'subtitle-list');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
const versionId = request.nextUrl.searchParams.get('versionId')?.trim() || null;
const subtitles = await db.videoSubtitle.findMany({
where: {
version: {
videoParentId: videoId,
...(versionId ? { id: versionId } : {}),
},
},
orderBy: [{ language: 'asc' }],
select: SUBTITLE_SELECT,
});
const response = successResponse({
subtitles: subtitles.map((subtitle) => shapeSubtitle(subtitle, context.canManageAssets)),
canManageSubtitles: context.canManageAssets,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error listing subtitles:', error);
return apiErrors.internalError('Failed to load subtitles');
}
}
// POST /api/videos/[videoId]/subtitles
export async function POST(request: NextRequest, { params }: RouteParams) {
let reservationId: string | null = null;
let billedUserId: string | null = null;
let storedObjectKey: string | null = null;
try {
const contentLength = request.headers.get('content-length');
if (!contentLength) {
return apiErrors.badRequest('Missing Content-Length header');
}
const bodySize = Number.parseInt(contentLength, 10);
if (!Number.isFinite(bodySize) || bodySize <= 0) {
return apiErrors.badRequest('Invalid Content-Length header');
}
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
}
const limited = await rateLimit(request, 'subtitle-create');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
// A subtitle is part of the delivered cut rather than a comment attachment, so it
// takes the editor permission and never the commenter one. Guests and share-link
// viewers can read the tracks but cannot add them.
if (!context.viewerUserId || !context.canManageAssets) {
return apiErrors.forbidden('Access denied');
}
const formData = await request.formData();
const files = formData.getAll('subtitle');
if (files.length !== 1 || !(files[0] instanceof File)) {
return apiErrors.badRequest('No subtitle file provided');
}
const file = files[0];
if (file.size > MAX_SUBTITLE_FILE_SIZE) {
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
}
if (!getSubtitleExtension(file.name)) {
return apiErrors.badRequest('Subtitle must be a .srt or .vtt file');
}
const versionIdValue = formData.get('versionId');
if (typeof versionIdValue !== 'string' || !versionIdValue.trim()) {
return apiErrors.badRequest('versionId is required');
}
const versionId = versionIdValue.trim();
const language = normalizeSubtitleLanguage(formData.get('language'));
if (!language) {
return apiErrors.badRequest('language must be a BCP-47 tag such as "tr" or "en-US"');
}
const label = sanitizeSubtitleLabel(formData.get('label'), language.toUpperCase());
const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId },
select: { id: true },
});
if (!version) return apiErrors.notFound('Version');
const existing = await db.videoSubtitle.findUnique({
where: { versionId_language: { versionId, language } },
select: { id: true, sourceUrl: true },
});
if (!existing) {
const trackCount = await db.videoSubtitle.count({ where: { versionId } });
if (trackCount >= MAX_SUBTITLES_PER_VERSION) {
return apiErrors.badRequest(
`A version can hold at most ${MAX_SUBTITLES_PER_VERSION} subtitle tracks`
);
}
}
const normalized = normalizeSubtitleFile(new Uint8Array(await file.arrayBuffer()));
if (!normalized.ok) {
return apiErrors.badRequest(normalized.error);
}
const body = Buffer.from(normalized.vtt, 'utf8');
const sizeBytes = BigInt(body.byteLength);
billedUserId = context.video.project.workspace.ownerId;
const reserveResult = await reserveStorageQuota(
billedUserId,
sizeBytes,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
if ('error' in reserveResult) return reserveResult.error;
reservationId = reserveResult.reservationId;
const fileName = `${randomUUID()}.vtt`;
const objectKey = `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: objectKey,
Body: body,
ContentType: SUBTITLE_CONTENT_TYPE,
})
);
storedObjectKey = objectKey;
const created = await db.$transaction(async (tx) => {
if (existing) {
await tx.videoSubtitle.delete({ where: { id: existing.id } });
}
return tx.videoSubtitle.create({
data: {
versionId,
language,
label,
sourceUrl: subtitleFileNameToProxyUrl(fileName),
sizeBytes,
billedUserId: billedUserId as string,
uploadedByUserId: context.viewerUserId,
},
select: SUBTITLE_SELECT,
});
});
// The row is committed, so the bytes are counted by the usage sum and the hold that
// stood in for them until now is no longer needed.
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
reservationId = null;
storedObjectKey = null;
if (existing) {
// Best effort: the replaced track is already unreachable, and a stranded object is
// a cleanup problem rather than a reason to fail an upload that succeeded.
const staleKey = subtitleProxyPathToObjectKey(existing.sourceUrl);
if (staleKey) {
try {
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: staleKey }));
} catch (deleteError) {
logError('Failed to delete replaced subtitle object:', deleteError);
}
}
}
const response = successResponse(shapeSubtitle(created, true), 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
if (storedObjectKey) {
try {
await r2Client.send(
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: storedObjectKey })
);
} catch (cleanupError) {
logError('Failed to clean up subtitle object after a failed upload:', cleanupError);
}
}
await releaseStorageReservation(
reservationId,
billedUserId,
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
);
logError('Error uploading subtitle:', error);
return apiErrors.internalError('Failed to upload subtitle');
}
}
+51
View File
@@ -38,6 +38,8 @@ import type {
} from '@/components/video-page/types'; } from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals'; import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets'; import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
import { useSubtitles } from '@/components/video-page/hooks/use-subtitles';
import { useYoutubeCaptions } from '@/components/video-page/hooks/use-youtube-captions';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils'; import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
@@ -275,6 +277,24 @@ export function VideoPageContent({
}, [video?.versions, activeVersionId]); }, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId; const activeProviderId = activeVersion?.providerId;
const speedOptions = getSpeedOptionsForProvider(activeProviderId); const speedOptions = getSpeedOptionsForProvider(activeProviderId);
// Only the providers that play through our own <video> element can carry a <track>.
// A YouTube version is an iframe we do not control, and it brings its own captions.
const supportsSubtitles = activeProviderId === 'bunny' || activeProviderId === 'r2';
const {
subtitles,
subtitleTrackKey,
canManageSubtitles,
activeSubtitleLanguage,
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
} = useSubtitles({
videoId,
versionId: activeVersionId,
videoRef,
supportsSubtitles,
});
const activeVersionDuration = activeVersion?.duration; const activeVersionDuration = activeVersion?.duration;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const embedUrl = useMemo(() => { const embedUrl = useMemo(() => {
@@ -305,6 +325,7 @@ export function VideoPageContent({
const { const {
isReady, isReady,
youtubeModuleRevision,
bunnyPlaybackState, bunnyPlaybackState,
currentTime, currentTime,
setCurrentTime, setCurrentTime,
@@ -359,6 +380,27 @@ export function VideoPageContent({
setViewingAnnotation, setViewingAnnotation,
}); });
const { youtubeCaptionTracks, activeYoutubeCaptionLanguage, selectYoutubeCaptionLanguage } =
useYoutubeCaptions({
videoId,
versionId: activeVersionId,
playerRef,
enabled: activeProviderId === 'youtube',
isReady,
moduleRevision: youtubeModuleRevision,
});
// One CC menu, two sources behind it. A YouTube version can only offer the captions the
// video already carries, so nothing there is ours to manage.
const isYoutubeVersion = activeProviderId === 'youtube';
const subtitleTracks = isYoutubeVersion ? youtubeCaptionTracks : subtitles;
const activeCaptionLanguage = isYoutubeVersion
? activeYoutubeCaptionLanguage
: activeSubtitleLanguage;
const selectCaptionLanguage = isYoutubeVersion
? selectYoutubeCaptionLanguage
: selectSubtitleLanguage;
const { const {
savedProgress, savedProgress,
showResumePrompt, showResumePrompt,
@@ -823,6 +865,15 @@ export function VideoPageContent({
selectedQualityLevel={selectedQualityLevel} selectedQualityLevel={selectedQualityLevel}
qualityOptions={qualityOptions} qualityOptions={qualityOptions}
handleQualityChange={handleQualityChange} handleQualityChange={handleQualityChange}
subtitles={subtitles}
subtitleTracks={subtitleTracks}
subtitleTrackKey={subtitleTrackKey}
activeSubtitleLanguage={activeCaptionLanguage}
onSelectSubtitleLanguage={selectCaptionLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={uploadSubtitle}
onDeleteSubtitle={deleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}
speedOptions={speedOptions} speedOptions={speedOptions}
handleSpeedChange={handleSpeedChange} handleSpeedChange={handleSpeedChange}
@@ -0,0 +1,32 @@
/**
* The chosen subtitle language, remembered per video the way a player is expected to.
*
* Shared by both caption paths, so a viewer who turned Turkish on for a Bunny-hosted cut
* gets Turkish again on the YouTube version of the same video.
*/
function preferenceKey(videoId: string): string {
return `openframe:subtitle-language:${videoId}`;
}
export function readStoredSubtitleLanguage(videoId: string): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(preferenceKey(videoId));
} catch {
return null;
}
}
export function writeStoredSubtitleLanguage(videoId: string, language: string | null): void {
if (typeof window === 'undefined') return;
try {
if (language) {
window.localStorage.setItem(preferenceKey(videoId), language);
} else {
window.localStorage.removeItem(preferenceKey(videoId));
}
} catch {
// A browser with storage disabled still gets subtitles, just not a remembered choice.
}
}
@@ -0,0 +1,270 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { Subtitle } from '@/components/video-page/types';
interface UseSubtitlesParams {
videoId: string;
versionId: string | null;
videoRef: RefObject<HTMLVideoElement | null>;
/** Only the providers we play through our own element can carry a track. */
supportsSubtitles: boolean;
}
/**
* A wiped track is remounted at most this many times per version. A file that really is
* empty cannot reach storage (the upload route refuses one), so the cap only exists so a
* surprise can never turn into a fetch loop.
*/
const MAX_TRACK_REPAIRS = 3;
export function useSubtitles({
videoId,
versionId,
videoRef,
supportsSubtitles,
}: UseSubtitlesParams) {
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
const [canManageSubtitles, setCanManageSubtitles] = useState(false);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
const [isUploadingSubtitle, setIsUploadingSubtitle] = useState(false);
// Bumped to remount the <track> elements when something empties them. See the effect
// below for what does that and why remounting is the fix.
const [trackEpoch, setTrackEpoch] = useState(0);
// The stored preference is applied once per version, not on every list refresh: turning
// subtitles off and then deleting an unrelated track must not switch them back on.
const appliedPreferenceForVersionRef = useRef<string | null>(null);
const loadedLanguagesRef = useRef<Set<string>>(new Set());
const repairCountRef = useRef(0);
useEffect(() => {
loadedLanguagesRef.current.clear();
repairCountRef.current = 0;
}, [versionId]);
const refresh = useCallback(async () => {
if (!versionId || !supportsSubtitles) {
setSubtitles([]);
setCanManageSubtitles(false);
return;
}
try {
const res = await fetch(
`/api/videos/${videoId}/subtitles?versionId=${encodeURIComponent(versionId)}`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const payload = await res.json();
const list: Subtitle[] = Array.isArray(payload?.data?.subtitles)
? payload.data.subtitles
: [];
setSubtitles(list);
setCanManageSubtitles(Boolean(payload?.data?.canManageSubtitles));
} catch {
// A failed list leaves the player without tracks, which is the same as having none.
}
}, [supportsSubtitles, versionId, videoId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!versionId) return;
if (appliedPreferenceForVersionRef.current === versionId) return;
if (subtitles.length === 0) return;
appliedPreferenceForVersionRef.current = versionId;
const stored = readStoredSubtitleLanguage(videoId);
if (stored && subtitles.some((subtitle) => subtitle.language === stored)) {
setActiveLanguage(stored);
}
}, [subtitles, versionId, videoId]);
// A track that is no longer in the list cannot stay selected.
useEffect(() => {
if (!activeLanguage) return;
if (subtitles.some((subtitle) => subtitle.language === activeLanguage)) return;
setActiveLanguage(null);
}, [activeLanguage, subtitles]);
/**
* React renders the <track> elements; their display mode is set here rather than through
* the `default` attribute, which the browser only honours on first load and which would
* fight the user's choice on every re-render.
*
* The second job here is repair. hls.js empties every text track on the media element,
* ours included, each time it loads a manifest (`_cleanTracks()` in its timeline
* controller). That fires on the initial load and again on every source switch, so a
* viewer who flips quality would watch the subtitles vanish for good: the file has
* already been fetched, so the browser never parses it a second time. Remounting the
* track element under a new key is what makes it fetch again.
*/
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const findActiveTrack = (): TextTrack | null => {
if (!activeLanguage) return null;
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
if (tracks[index].language === activeLanguage) return tracks[index];
}
return null;
};
const applyModes = () => {
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
const shouldShow = Boolean(activeLanguage) && track.language === activeLanguage;
track.mode = shouldShow ? 'showing' : 'disabled';
if (!shouldShow) continue;
// The control bar sits over the bottom of the frame in fullscreen, so cues are
// lifted clear of it instead of landing underneath.
const cues = track.cues;
if (!cues) continue;
for (let cueIndex = 0; cueIndex < cues.length; cueIndex += 1) {
const cue = cues[cueIndex] as VTTCue;
if (typeof cue.line !== 'undefined') {
cue.snapToLines = true;
cue.line = -3;
}
}
}
};
const markLoaded = (event: Event) => {
const element = event.currentTarget as HTMLTrackElement;
loadedLanguagesRef.current.add(element.srclang);
applyModes();
};
const repairIfEmptied = () => {
if (!activeLanguage) return;
// Before the file has loaded a track legitimately has no cues, so only a track we
// have seen load and that is now empty counts as wiped.
if (!loadedLanguagesRef.current.has(activeLanguage)) return;
const track = findActiveTrack();
if (!track || (track.cues?.length ?? 0) > 0) return;
if (repairCountRef.current >= MAX_TRACK_REPAIRS) return;
repairCountRef.current += 1;
loadedLanguagesRef.current.delete(activeLanguage);
setTrackEpoch((epoch) => epoch + 1);
};
applyModes();
// A track's cues are null until the browser has fetched the file, which it only does
// once the track is not disabled. The lift above therefore has to run again on load.
const trackElements = Array.from(videoEl.querySelectorAll('track'));
trackElements.forEach((element) => element.addEventListener('load', markLoaded));
videoEl.textTracks.addEventListener('addtrack', applyModes);
// `loadeddata` catches a source switch while paused; `timeupdate` catches everything
// else within a quarter of a second of playback.
videoEl.addEventListener('loadeddata', repairIfEmptied);
videoEl.addEventListener('timeupdate', repairIfEmptied);
return () => {
trackElements.forEach((element) => element.removeEventListener('load', markLoaded));
videoEl.textTracks.removeEventListener('addtrack', applyModes);
videoEl.removeEventListener('loadeddata', repairIfEmptied);
videoEl.removeEventListener('timeupdate', repairIfEmptied);
};
}, [activeLanguage, subtitles, trackEpoch, videoRef, versionId]);
const selectSubtitleLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
},
[versionId, videoId]
);
const uploadSubtitle = useCallback(
async (file: File, language: string, label: string): Promise<string | null> => {
if (!versionId) return 'No version selected';
setIsUploadingSubtitle(true);
try {
const formData = new FormData();
formData.append('subtitle', file);
formData.append('versionId', versionId);
formData.append('language', language);
formData.append('label', label);
const res = await fetch(`/api/videos/${videoId}/subtitles`, {
method: 'POST',
body: formData,
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
return payload?.error?.message || payload?.error || 'Failed to upload subtitle';
}
await refresh();
selectSubtitleLanguage(language.toLowerCase());
return null;
} catch {
return 'Failed to upload subtitle';
} finally {
setIsUploadingSubtitle(false);
}
},
[refresh, selectSubtitleLanguage, versionId, videoId]
);
const deleteSubtitle = useCallback(
async (subtitleId: string): Promise<string | null> => {
try {
const res = await fetch(`/api/videos/${videoId}/subtitles/${subtitleId}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
return payload?.error?.message || payload?.error || 'Failed to delete subtitle';
}
await refresh();
return null;
} catch {
return 'Failed to delete subtitle';
}
},
[refresh, videoId]
);
return useMemo(
() => ({
subtitles,
canManageSubtitles,
activeSubtitleLanguage: activeLanguage,
subtitleTrackKey: String(trackEpoch),
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
refreshSubtitles: refresh,
}),
[
activeLanguage,
canManageSubtitles,
deleteSubtitle,
isUploadingSubtitle,
refresh,
selectSubtitleLanguage,
subtitles,
trackEpoch,
uploadSubtitle,
]
);
}
@@ -81,6 +81,10 @@ export function useVideoPlayer({
}: UseVideoPlayerParams) { }: UseVideoPlayerParams) {
const [isApiLoaded, setIsApiLoaded] = useState(false); const [isApiLoaded, setIsApiLoaded] = useState(false);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
// Bumped every time the YouTube player loads or unloads a module. It is the only
// signal that `getOption('captions', ...)` will answer, so the captions hook waits
// on it rather than polling.
const [youtubeModuleRevision, setYoutubeModuleRevision] = useState(0);
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none'); const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [videoDuration, setVideoDuration] = useState(0); const [videoDuration, setVideoDuration] = useState(0);
@@ -315,6 +319,9 @@ export function useVideoPlayer({
const dur = event.target.getDuration(); const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur); if (dur > 0) setVideoDuration(dur);
}, },
onApiChange: () => {
setYoutubeModuleRevision((revision) => revision + 1);
},
onStateChange: (event: YT.OnStateChangeEvent) => { onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING); setIsPlaying(event.data === YT.PlayerState.PLAYING);
@@ -1344,6 +1351,7 @@ export function useVideoPlayer({
return { return {
isReady, isReady,
youtubeModuleRevision,
bunnyPlaybackState, bunnyPlaybackState,
currentTime, currentTime,
setCurrentTime, setCurrentTime,
@@ -0,0 +1,177 @@
'use client';
// Same exemption as the players themselves: this hook mirrors an external player's
// caption state into React, which is the case the rule cannot distinguish.
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { PlayerAdapter, SubtitleTrackOption } from '@/components/video-page/types';
interface UseYoutubeCaptionsParams {
videoId: string;
versionId: string | null;
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
/** The active version is a YouTube one. */
enabled: boolean;
isReady: boolean;
/** Incremented by the player on every onApiChange. */
moduleRevision: number;
}
/** One entry of `getOption('captions', 'tracklist')`. Only these fields are relied on. */
type YoutubeCaptionTrack = {
languageCode?: string;
languageName?: string;
displayName?: string;
};
const CAPTIONS_MODULE = 'captions';
function asYoutubePlayer(
player: YT.Player | PlayerAdapter | null
): (YT.Player & { loadModule?: unknown }) | null {
if (!player) return null;
const candidate = player as YT.Player;
return typeof candidate.loadModule === 'function' ? candidate : null;
}
/**
* Drives YouTube's own captions from our control bar.
*
* A YouTube version plays inside an iframe we do not own, so a <track> element is not an
* option and neither is an uploaded file: the only captions that exist for it are the ones
* the video already carries. The player is embedded with controls=0, which hides
* YouTube's CC button along with the rest of its chrome, so without this the captions
* would be unreachable even when they exist.
*/
export function useYoutubeCaptions({
videoId,
versionId,
playerRef,
enabled,
isReady,
moduleRevision,
}: UseYoutubeCaptionsParams) {
const [tracks, setTracks] = useState<SubtitleTrackOption[]>([]);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
// Read inside effects that must not re-run when the selection changes.
const activeLanguageRef = useRef<string | null>(null);
useEffect(() => {
activeLanguageRef.current = activeLanguage;
}, [activeLanguage]);
const appliedPreferenceForVersionRef = useRef<string | null>(null);
/**
* The caption state we last pushed into the player, or `undefined` before the first
* push. Loading and unloading a module both fire onApiChange, so an effect that reacted
* to every revision by unloading again would answer its own event forever.
*/
const appliedLanguageRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
setTracks([]);
setActiveLanguage(null);
appliedLanguageRef.current = undefined;
}, [versionId]);
// Loading the module is what makes the track list readable, and it also switches
// captions on. The probe below turns them straight back off for a viewer who has not
// asked for them: at this point the video is at its first frame with no cue to draw,
// so there is nothing to flash.
useEffect(() => {
if (!enabled || !isReady) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
player.loadModule(CAPTIONS_MODULE);
} catch {
// An older or restricted player without the module API simply has no captions.
}
}, [enabled, isReady, playerRef, versionId]);
useEffect(() => {
if (!enabled || !isReady || moduleRevision === 0) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
let rawTracks: YoutubeCaptionTrack[] = [];
try {
rawTracks = player.getOption<YoutubeCaptionTrack[]>(CAPTIONS_MODULE, 'tracklist') ?? [];
} catch {
rawTracks = [];
}
const mapped: SubtitleTrackOption[] = rawTracks
.filter((track): track is YoutubeCaptionTrack & { languageCode: string } =>
Boolean(track?.languageCode)
)
.map((track) => ({
id: `youtube:${track.languageCode}`,
language: track.languageCode.toLowerCase(),
label: track.displayName || track.languageName || track.languageCode.toUpperCase(),
canDelete: false,
}));
setTracks(mapped);
const stored =
versionId && appliedPreferenceForVersionRef.current !== versionId
? readStoredSubtitleLanguage(videoId)
: null;
if (versionId) appliedPreferenceForVersionRef.current = versionId;
const wanted =
activeLanguageRef.current ??
(stored && mapped.some((track) => track.language === stored) ? stored : null);
if (appliedLanguageRef.current === wanted) return;
appliedLanguageRef.current = wanted;
try {
if (wanted) {
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: wanted });
setActiveLanguage(wanted);
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Same as above: a player that will not take the option has no captions to give.
}
}, [enabled, isReady, moduleRevision, playerRef, versionId, videoId]);
const selectCaptionLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
appliedLanguageRef.current = language;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
if (language) {
player.loadModule(CAPTIONS_MODULE);
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: language });
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Nothing to recover: the menu already reflects the choice, and a player that
// refuses the module was never going to show captions.
}
},
[playerRef, versionId, videoId]
);
return useMemo(
() => ({
youtubeCaptionTracks: enabled ? tracks : [],
activeYoutubeCaptionLanguage: enabled ? activeLanguage : null,
selectYoutubeCaptionLanguage: selectCaptionLanguage,
}),
[activeLanguage, enabled, selectCaptionLanguage, tracks]
);
}
+57 -2
View File
@@ -32,7 +32,13 @@ import {
type AnnotationStroke, type AnnotationStroke,
} from '@/components/annotation-canvas'; } from '@/components/annotation-canvas';
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils'; import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types'; import { SubtitleControls } from '@/components/video-page/subtitle-controls';
import type {
BunnyQualityOption,
CommentMarker,
Subtitle,
SubtitleTrackOption,
} from '@/components/video-page/types';
interface PlayerCoreProps { interface PlayerCoreProps {
activeVersionId: string | null; activeVersionId: string | null;
@@ -85,6 +91,24 @@ interface PlayerCoreProps {
selectedQualityLevel: number; selectedQualityLevel: number;
qualityOptions: BunnyQualityOption[]; qualityOptions: BunnyQualityOption[];
handleQualityChange: (level: number) => void; handleQualityChange: (level: number) => void;
/** Uploaded tracks, rendered as <track> elements. Empty for a YouTube version. */
subtitles: Subtitle[];
/**
* What the CC menu offers, which is the list above for our own player and YouTube's
* own caption list for an embedded YouTube version.
*/
subtitleTracks: SubtitleTrackOption[];
/**
* Changes when a track has to be re-fetched. It is part of each <track> key because
* remounting the element is the only way to make the browser parse the file again.
*/
subtitleTrackKey: string;
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
playbackSpeed: number; playbackSpeed: number;
speedOptions: number[]; speedOptions: number[];
handleSpeedChange: (speed: number) => void; handleSpeedChange: (speed: number) => void;
@@ -153,6 +177,15 @@ export const PlayerCore = memo(function PlayerCore({
selectedQualityLevel, selectedQualityLevel,
qualityOptions, qualityOptions,
handleQualityChange, handleQualityChange,
subtitles,
subtitleTracks,
subtitleTrackKey,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
playbackSpeed, playbackSpeed,
speedOptions, speedOptions,
handleSpeedChange, handleSpeedChange,
@@ -208,7 +241,17 @@ export const PlayerCore = memo(function PlayerCore({
}} }}
preload="metadata" preload="metadata"
playsInline playsInline
/> >
{subtitles.map((subtitle) => (
<track
key={`${subtitle.id}:${subtitleTrackKey}`}
kind="subtitles"
src={subtitle.url}
srcLang={subtitle.language}
label={subtitle.label}
/>
))}
</video>
</div> </div>
</div> </div>
) : ( ) : (
@@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({
</DropdownMenu> </DropdownMenu>
)} )}
{activeProviderId && activeProviderId !== 'direct' && (
<SubtitleControls
subtitles={subtitleTracks}
activeSubtitleLanguage={activeSubtitleLanguage}
onSelectSubtitleLanguage={onSelectSubtitleLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={onUploadSubtitle}
onDeleteSubtitle={onDeleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
/>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs"> <Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
+303
View File
@@ -0,0 +1,303 @@
'use client';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Captions, Loader2, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { SubtitleTrackOption } from '@/components/video-page/types';
const COMMON_LANGUAGES = [
'tr',
'en',
'de',
'fr',
'es',
'it',
'pt',
'nl',
'pl',
'ru',
'ar',
'ja',
'ko',
'zh',
'hi',
] as const;
const OTHER_LANGUAGE = '__other__';
/** A file named `cut-v3.tr.srt` already says which language it is. */
const LANGUAGE_FROM_FILENAME = /\.([a-z]{2,3}(?:-[a-z0-9]{2,8})?)\.(?:srt|vtt)$/i;
function describeLanguage(tag: string): string {
try {
const displayNames = new Intl.DisplayNames(undefined, { type: 'language' });
return displayNames.of(tag) || tag.toUpperCase();
} catch {
return tag.toUpperCase();
}
}
function guessLanguageFromFileName(fileName: string): string | null {
const match = LANGUAGE_FROM_FILENAME.exec(fileName);
return match ? match[1].toLowerCase() : null;
}
interface SubtitleControlsProps {
/**
* What the menu lists. For a Bunny or R2 version these are the tracks uploaded to this
* cut; for a YouTube version they are the captions the video already carries, which is
* why the shape is narrower than a stored subtitle.
*/
subtitles: SubtitleTrackOption[];
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
}
export const SubtitleControls = memo(function SubtitleControls({
subtitles,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
}: SubtitleControlsProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [languageChoice, setLanguageChoice] = useState<string>('tr');
const [customLanguage, setCustomLanguage] = useState('');
const [label, setLabel] = useState('');
const activeSubtitle = useMemo(
() => subtitles.find((subtitle) => subtitle.language === activeSubtitleLanguage) ?? null,
[activeSubtitleLanguage, subtitles]
);
const resolvedLanguage = (
languageChoice === OTHER_LANGUAGE ? customLanguage : languageChoice
).trim();
const handleFileChosen = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
// Clearing the input lets the same file be picked again after a failed upload.
event.target.value = '';
if (!file) return;
const guessed = guessLanguageFromFileName(file.name);
const known = guessed && (COMMON_LANGUAGES as readonly string[]).includes(guessed);
setLanguageChoice(known ? (guessed as string) : guessed ? OTHER_LANGUAGE : 'tr');
setCustomLanguage(known ? '' : (guessed ?? ''));
setLabel('');
setPendingFile(file);
}, []);
const handleUpload = useCallback(async () => {
if (!pendingFile || !resolvedLanguage) return;
const finalLabel = label.trim() || describeLanguage(resolvedLanguage);
const error = await onUploadSubtitle(pendingFile, resolvedLanguage, finalLabel);
if (error) {
toast.error(error);
return;
}
toast.success('Subtitle added');
setPendingFile(null);
}, [label, onUploadSubtitle, pendingFile, resolvedLanguage]);
const handleDelete = useCallback(
async (subtitle: SubtitleTrackOption) => {
const error = await onDeleteSubtitle(subtitle.id);
if (error) {
toast.error(error);
return;
}
toast.success(`${subtitle.label} removed`);
},
[onDeleteSubtitle]
);
if (subtitles.length === 0 && !canManageSubtitles) return null;
const replacesExisting = subtitles.some(
(subtitle) => subtitle.language === resolvedLanguage.toLowerCase()
);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={activeSubtitle ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1 text-xs"
title="Subtitles"
>
<Captions className="h-3.5 w-3.5" />
{activeSubtitle ? activeSubtitle.label : 'CC'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem
onClick={() => onSelectSubtitleLanguage(null)}
className={cn(!activeSubtitleLanguage && 'font-bold text-primary')}
>
Off
</DropdownMenuItem>
{subtitles.map((subtitle) => (
<DropdownMenuItem
key={subtitle.id}
onClick={() => onSelectSubtitleLanguage(subtitle.language)}
className={cn(
'flex items-center justify-between gap-2',
subtitle.language === activeSubtitleLanguage && 'font-bold text-primary'
)}
>
<span className="truncate">{subtitle.label}</span>
{subtitle.canDelete && (
<button
type="button"
aria-label={`Delete ${subtitle.label} subtitle`}
className="text-muted-foreground hover:text-destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDelete(subtitle);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</DropdownMenuItem>
))}
{canManageSubtitles && (
<>
{subtitles.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingSubtitle}
>
<Upload className="h-3.5 w-3.5 mr-2" />
Add subtitle
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<input
ref={fileInputRef}
type="file"
accept=".srt,.vtt,text/vtt,application/x-subrip"
className="hidden"
onChange={handleFileChosen}
/>
<Dialog
open={!!pendingFile}
onOpenChange={(open) => {
if (!open && !isUploadingSubtitle) setPendingFile(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add subtitle</DialogTitle>
<DialogDescription>
{pendingFile?.name} is attached to this version only, because cue timings belong to
one cut. SRT files are converted to WebVTT on upload.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subtitle-language">Language</Label>
<Select value={languageChoice} onValueChange={setLanguageChoice}>
<SelectTrigger id="subtitle-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((tag) => (
<SelectItem key={tag} value={tag}>
{describeLanguage(tag)}
</SelectItem>
))}
<SelectItem value={OTHER_LANGUAGE}>Other</SelectItem>
</SelectContent>
</Select>
{languageChoice === OTHER_LANGUAGE && (
<Input
value={customLanguage}
onChange={(event) => setCustomLanguage(event.target.value)}
placeholder="Language tag, e.g. en-US"
maxLength={20}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-label">Label</Label>
<Input
id="subtitle-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={resolvedLanguage ? describeLanguage(resolvedLanguage) : 'Türkçe'}
maxLength={60}
/>
</div>
{replacesExisting && (
<p className="text-xs text-muted-foreground">
This version already has a track in that language. Uploading replaces it.
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingFile(null)}
disabled={isUploadingSubtitle}
>
Cancel
</Button>
<Button
onClick={() => void handleUpload()}
disabled={isUploadingSubtitle || !resolvedLanguage}
>
{isUploadingSubtitle && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
+21
View File
@@ -35,6 +35,27 @@ export interface VideoAsset {
canDelete: boolean; canDelete: boolean;
} }
/**
* What the player's CC menu needs to know about one track. Our own uploaded tracks and
* the ones a YouTube video brings with it are different things underneath, and the menu
* is the one place that does not have to care.
*/
export interface SubtitleTrackOption {
id: string;
language: string;
label: string;
canDelete: boolean;
}
export interface Subtitle extends SubtitleTrackOption {
versionId: string;
url: string;
sizeBytes: number;
createdAt: string;
updatedAt: string;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
}
export interface ApprovalDecision { export interface ApprovalDecision {
id: string; id: string;
approverId: string; approverId: string;
+6
View File
@@ -235,6 +235,12 @@ export type BuildProjectDownloadManifestOptions = {
includeAssets?: boolean; 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( export function buildProjectDownloadManifest(
projectName: string, projectName: string,
videos: VideoRow[], videos: VideoRow[],
+26 -4
View File
@@ -3,6 +3,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { runWithConcurrency } from '@/lib/async-pool'; import { runWithConcurrency } from '@/lib/async-pool';
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation'; import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
/** The path prefix for images served by the upload API. */ /** 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 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). * Collect all media URLs from comments under a given video (all versions).
*/ */
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> { 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({ db.comment.findMany({
where: { where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }], OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -101,6 +102,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
where: { videoParentId: videoId, providerId: 'r2' }, where: { videoParentId: videoId, providerId: 'r2' },
select: { originalUrl: true, thumbnailUrl: true }, select: { originalUrl: true, thumbnailUrl: true },
}), }),
db.videoSubtitle.findMany({
where: { version: { videoParentId: videoId } },
select: { sourceUrl: true },
}),
]); ]);
const urls: string[] = []; const urls: string[] = [];
comments.forEach((c) => { comments.forEach((c) => {
@@ -114,6 +119,9 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
if (version.originalUrl) urls.push(version.originalUrl); if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl); if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
}); });
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls; 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. * Collect all media URLs from comments under all videos in a project.
*/ */
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> { 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({ db.comment.findMany({
where: { where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }], OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -140,6 +148,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
where: { providerId: 'r2', video: { projectId } }, where: { providerId: 'r2', video: { projectId } },
select: { originalUrl: true, thumbnailUrl: true }, select: { originalUrl: true, thumbnailUrl: true },
}), }),
db.videoSubtitle.findMany({
where: { version: { video: { projectId } } },
select: { sourceUrl: true },
}),
]); ]);
const urls: string[] = []; const urls: string[] = [];
comments.forEach((c) => { comments.forEach((c) => {
@@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
if (version.originalUrl) urls.push(version.originalUrl); if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl); if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
}); });
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls; 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. * Collect all media URLs from comments under all projects in a workspace.
*/ */
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> { 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({ db.comment.findMany({
where: { where: {
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }], OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
@@ -179,6 +194,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
where: { providerId: 'r2', video: { project: { workspaceId } } }, where: { providerId: 'r2', video: { project: { workspaceId } } },
select: { originalUrl: true, thumbnailUrl: true }, select: { originalUrl: true, thumbnailUrl: true },
}), }),
db.videoSubtitle.findMany({
where: { version: { video: { project: { workspaceId } } } },
select: { sourceUrl: true },
}),
]); ]);
const urls: string[] = []; const urls: string[] = [];
comments.forEach((c) => { comments.forEach((c) => {
@@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
if (version.originalUrl) urls.push(version.originalUrl); if (version.originalUrl) urls.push(version.originalUrl);
if (version.thumbnailUrl) urls.push(version.thumbnailUrl); if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
}); });
subtitles.forEach((subtitle) => {
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
});
return urls; 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 // call sites gate the file name on a strict pattern first, and a fourth that forgot would
// otherwise hand a traversal straight to GetObject. // otherwise hand a traversal straight to GetObject.
const SAFE_MEDIA_OBJECT_KEY = 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 { export function isSafeR2MediaKey(key: string): boolean {
return SAFE_MEDIA_OBJECT_KEY.test(key); 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-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { 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 '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 — debounced on client but protect against scripted callers
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute 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', R2_VIDEO: 'R2_VIDEO',
/** A direct upload to Bunny, where the bytes never pass through us. */ /** A direct upload to Bunny, where the bytes never pass through us. */
BUNNY: 'BUNNY', BUNNY: 'BUNNY',
/** A subtitle track, which lands in our own S3-compatible storage whatever hosts the video. */
SUBTITLE: 'SUBTITLE',
} as const; } as const;
export type UploadReservationPurpose = export type UploadReservationPurpose =
@@ -147,14 +149,15 @@ class QuotaExceededError extends Error {}
* every upload. * every upload.
*/ */
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> { export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([ const [r2AssetRows, r2VideoRows, subtitleRows, bunnyUserBytes, reservationRows] =
db.$queryRaw<[{ total: bigint }]>` await Promise.all([
db.$queryRaw<[{ total: bigint }]>`
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
FROM video_assets FROM video_assets
WHERE "billedUserId" = ${userId} WHERE "billedUserId" = ${userId}
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO') 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 SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
FROM video_versions vv FROM video_versions vv
INNER JOIN videos v ON v.id = vv."videoParentId" 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} WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2' 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 SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
FROM upload_reservations FROM upload_reservations
WHERE "billedUserId" = ${userId} WHERE "billedUserId" = ${userId}
AND "expiresAt" > NOW() AND "expiresAt" > NOW()
`, `,
]); ]);
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0); const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0); const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
const subtitleBytes = subtitleRows[0]?.total ?? BigInt(0);
const bunnyBytes = BigInt(bunnyUserBytes); const bunnyBytes = BigInt(bunnyUserBytes);
const reservedBytes = reservationRows[0]?.total ?? BigInt(0); 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} WHERE w."ownerId" = ${userId}
AND vv."providerId" = 'r2' 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 // Read active (non-expired) reservations under the same lock
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>` const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
+298
View File
@@ -0,0 +1,298 @@
/**
* 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 };
}
const CUE_TAG = /<[^<>]*>/g;
/**
* Angle brackets outside a recognised tag are escaped one character at a time rather than
* the offending tag being deleted whole. Deleting is what lets a filter like this be
* reassembled around: strip the `<b>` out of `<scr<b>ipt>` and the two halves close up
* into a tag that was never written. Nothing closes up when the leftovers are escaped
* instead, and the same escaping takes care of `-->`, which would otherwise be read back
* as a timing line and split the cue in two. `&` is left alone so a file that already
* spells its entities properly keeps them.
*/
function escapeCueText(text: string): string {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function sanitizeCueLine(line: string): string {
// 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.
const withoutOverrides = line.replace(/\{\\[^}]*\}/g, '');
let sanitized = '';
let cursor = 0;
CUE_TAG.lastIndex = 0;
for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) {
sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index));
if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) {
sanitized += match[0];
}
cursor = match.index + match[0].length;
}
sanitized += escapeCueText(withoutOverrides.slice(cursor));
return sanitized.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;
// The cap can land inside an escape the sanitiser wrote, so a dangling `&lt` tail is
// trimmed rather than left for the parser to render as text.
const text = textLines
.join('\n')
.slice(0, MAX_CUE_TEXT_LENGTH)
.replace(/&[a-z]{0,5}$/i, '')
.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}`;
}
@@ -0,0 +1,39 @@
-- Subtitle tracks hang off a version, not off the video: re-editing a cut shifts
-- every cue, so a track attached to the parent would be wrong for every version
-- but the one it was written against.
CREATE TABLE "video_subtitles" (
"id" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"language" TEXT NOT NULL,
"label" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"size_bytes" BIGINT NOT NULL DEFAULT 0,
"billedUserId" TEXT NOT NULL,
"uploadedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "video_subtitles_pkey" PRIMARY KEY ("id")
);
-- One stored object belongs to exactly one row, so the reference check that runs
-- before an object delete cannot be fooled by a second row pointing at the file.
CREATE UNIQUE INDEX "video_subtitles_sourceUrl_key" ON "video_subtitles"("sourceUrl");
-- Re-uploading a language replaces the track rather than stacking a second one,
-- which would leave the player with two tracks labelled the same.
CREATE UNIQUE INDEX "video_subtitles_versionId_language_key" ON "video_subtitles"("versionId", "language");
CREATE INDEX "video_subtitles_versionId_idx" ON "video_subtitles"("versionId");
-- The storage quota sums this column per billed user on every upload.
CREATE INDEX "video_subtitles_billedUserId_idx" ON "video_subtitles"("billedUserId");
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_versionId_fkey"
FOREIGN KEY ("versionId") REFERENCES "video_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_billedUserId_fkey"
FOREIGN KEY ("billedUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_uploadedByUserId_fkey"
FOREIGN KEY ("uploadedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+31
View File
@@ -42,6 +42,8 @@ model User {
comments Comment[] comments Comment[]
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy") uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo") billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
uploadedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleUploadedBy")
billedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleBilledTo")
projectMemberships ProjectMember[] projectMemberships ProjectMember[]
notificationSetting NotificationSetting? notificationSetting NotificationSetting?
watchProgress WatchProgress[] watchProgress WatchProgress[]
@@ -383,6 +385,7 @@ model VideoVersion {
comments Comment[] comments Comment[]
watchProgress WatchProgress[] watchProgress WatchProgress[]
approvalRequests ApprovalRequest[] approvalRequests ApprovalRequest[]
subtitles VideoSubtitle[]
@@unique([videoParentId, versionNumber]) @@unique([videoParentId, versionNumber])
@@index([videoParentId]) @@index([videoParentId])
@@ -418,6 +421,34 @@ model VideoAsset {
@@map("video_assets") @@map("video_assets")
} }
/// A subtitle track for one cut. Timings belong to a version rather than to the
/// video: re-editing shifts every cue, so a track attached to the parent would be
/// wrong for every version but the one it was written against.
model VideoSubtitle {
id String @id @default(cuid())
versionId String
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
/// BCP-47 tag, lowercased primary subtag, e.g. `tr`, `en-US`.
language String
label String
/// Always an /api/upload/subtitle/<uuid>.vtt path. The file itself lives in
/// S3-compatible storage whatever the video's own provider is, so a Bunny-hosted
/// video and an R2-hosted one take the same path through the player.
sourceUrl String @unique
sizeBytes BigInt @default(0) @map("size_bytes")
billedUserId String
billedUser User @relation("VideoSubtitleBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
uploadedByUserId String?
uploadedByUser User? @relation("VideoSubtitleUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([versionId, language])
@@index([versionId])
@@index([billedUserId])
@@map("video_subtitles")
}
model Comment { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
+48 -1
View File
@@ -82,6 +82,7 @@ import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route';
import * as uploadAudioRoute from '@/app/api/upload/audio/route'; import * as uploadAudioRoute from '@/app/api/upload/audio/route';
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route'; import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
import * as uploadImageRoute from '@/app/api/upload/image/route'; import * as uploadImageRoute from '@/app/api/upload/image/route';
import * as uploadSubtitleFileRoute from '@/app/api/upload/subtitle/[filename]/route';
import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route'; import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route'; import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route'; import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route';
@@ -92,6 +93,8 @@ import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route';
import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route'; import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route'; import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route';
import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route'; import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route';
import * as subtitleRoute from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route';
import * as subtitlesRoute from '@/app/api/videos/[videoId]/subtitles/route';
import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route'; import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
import * as watchRoute from '@/app/api/watch/[videoId]/route'; import * as watchRoute from '@/app/api/watch/[videoId]/route';
import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route'; import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route';
@@ -145,7 +148,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
// The count guard // The count guard
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES. // Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
const EXPECTED_ROUTE_MODULE_COUNT = 63; const EXPECTED_ROUTE_MODULE_COUNT = 66;
/** /**
* Routes that are public by design, and why. Everything else must reject an * Routes that are public by design, and why. Everything else must reject an
@@ -203,6 +206,7 @@ const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png'; const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm'; const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4'; const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
const SUBTITLE_FILENAME = '44444444-4444-4444-8444-444444444444.vtt';
interface Fixtures { interface Fixtures {
userId: string; userId: string;
@@ -217,6 +221,7 @@ interface Fixtures {
versionId: string; versionId: string;
commentId: string; commentId: string;
assetId: string; assetId: string;
subtitleId: string;
approvalRequestId: string; approvalRequestId: string;
feedbackId: string; feedbackId: string;
} }
@@ -280,6 +285,20 @@ async function seedFixtures(): Promise<Fixtures> {
sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`, sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
}); });
// A real track, so /api/upload/subtitle/[filename] resolves to a row and its
// refusal comes from the access check rather than from the reverse lookup.
const subtitle = await db.videoSubtitle.create({
data: {
versionId: version.id,
language: 'tr',
label: 'Türkçe',
sourceUrl: `/api/upload/subtitle/${SUBTITLE_FILENAME}`,
sizeBytes: BigInt(64),
billedUserId: owner.id,
uploadedByUserId: owner.id,
},
});
await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' }); await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
const approvalRequest = await createApprovalRequest({ const approvalRequest = await createApprovalRequest({
@@ -310,6 +329,7 @@ async function seedFixtures(): Promise<Fixtures> {
versionId: version.id, versionId: version.id,
commentId: comment.id, commentId: comment.id,
assetId: asset.id, assetId: asset.id,
subtitleId: subtitle.id,
approvalRequestId: approvalRequest.id, approvalRequestId: approvalRequest.id,
feedbackId: feedback.id, feedbackId: feedback.id,
}; };
@@ -595,6 +615,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
// and constructing a Request from a FormData does not set one. // and constructing a Request from a FormData does not set one.
headers: { 'content-length': '2048' }, headers: { 'content-length': '2048' },
}, },
{
file: 'upload/subtitle/[filename]/route.ts',
module: uploadSubtitleFileRoute,
url: () => `/api/upload/subtitle/${SUBTITLE_FILENAME}`,
params: () => ({ filename: SUBTITLE_FILENAME }),
},
{ {
file: 'upload/video/[filename]/route.ts', file: 'upload/video/[filename]/route.ts',
module: uploadVideoFileRoute, module: uploadVideoFileRoute,
@@ -669,6 +695,27 @@ const ROUTE_CASES: readonly RouteCase[] = [
// exact-status coverage lives in tests/api/assets-authz.test.ts. // exact-status coverage lives in tests/api/assets-authz.test.ts.
body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` }, body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` },
}, },
{
file: 'videos/[videoId]/subtitles/[subtitleId]/route.ts',
module: subtitleRoute,
url: (f) => `/api/videos/${f.videoId}/subtitles/${f.subtitleId}`,
params: (f) => ({ videoId: f.videoId, subtitleId: f.subtitleId }),
},
{
file: 'videos/[videoId]/subtitles/route.ts',
module: subtitlesRoute,
url: (f) => `/api/videos/${f.videoId}/subtitles`,
params: (f) => ({ videoId: f.videoId }),
// POST sizes the body before it does anything else, and a Request built from
// a FormData carries no Content-Length, so without this the anonymous call
// would stop on a 400 above the guard rather than on the guard.
headers: { 'content-length': '4096' },
rawBody: () => {
const form = new FormData();
form.append('subtitle', new File(['WEBVTT'], 'anon.vtt', { type: 'text/vtt' }));
return form;
},
},
{ {
file: 'watch/[videoId]/progress/route.ts', file: 'watch/[videoId]/progress/route.ts',
module: watchProgressRoute, module: watchProgressRoute,
+514
View File
@@ -0,0 +1,514 @@
// The subtitle family: list, upload, delete, and the proxy that serves the stored
// WebVTT back to the player.
//
// Two properties are worth pinning down here rather than in the unit suite.
//
// - The upload path is editor-only. Every other write under /api/videos/[videoId]
// is open to anyone who may comment, guests included, so a subtitle route that
// reached for `canUploadAssets` instead of `canManageAssets` would look correct
// next to its neighbours and would let a share-link viewer rewrite the captions
// on a delivered cut.
//
// - What lands in storage is the normalised file, never the bytes that were
// uploaded. The assertions below read the PutObject command rather than trusting
// the 201.
//
// tests/setup/api.ts stubs the named helpers in `@/lib/r2` but leaves `r2Client`
// real, and the real one throws on first use, so it is replaced with a recorder.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '@/lib/db';
import {
GET as listSubtitles,
POST as uploadSubtitle,
} from '@/app/api/videos/[videoId]/subtitles/route';
import { DELETE as deleteSubtitle } from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route';
import { GET as serveSubtitle } from '@/app/api/upload/subtitle/[filename]/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import { addProjectMember, createUser, seedVersion } from '../factories';
const r2 = vi.hoisted(() => ({
bucket: 'openframe-subtitle-test-bucket',
puts: [] as Array<{ key: string; body: string; contentType: string }>,
deletedKeys: [] as string[],
gets: [] as string[],
}));
vi.mock('@/lib/r2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/r2')>();
return {
...actual,
R2_BUCKET_NAME: r2.bucket,
r2Client: {
send: async (command: {
constructor: { name: string };
input?: { Key?: string; Body?: Buffer; ContentType?: string };
}) => {
const key = command.input?.Key ?? '';
switch (command.constructor.name) {
case 'PutObjectCommand':
r2.puts.push({
key,
body: Buffer.from(command.input?.Body ?? Buffer.alloc(0)).toString('utf8'),
contentType: command.input?.ContentType ?? '',
});
return {};
case 'DeleteObjectCommand':
r2.deletedKeys.push(key);
return {};
case 'GetObjectCommand': {
r2.gets.push(key);
const stored = r2.puts.find((put) => put.key === key);
if (!stored) {
const error = new Error('NoSuchKey');
error.name = 'NoSuchKey';
throw error;
}
return {
Body: new Response(stored.body).body,
ContentType: stored.contentType,
ContentLength: Buffer.byteLength(stored.body),
};
}
default:
return {};
}
},
},
};
});
const SRT_FILE = ['1', '00:00:01,000 --> 00:00:02,500', 'Merhaba', '', ''].join('\n');
const NORMALIZED_VTT = 'WEBVTT\n\n00:00:01.000 --> 00:00:02.500\nMerhaba\n';
const SUBTITLE_KEY = /^subtitles\/[0-9a-f-]{36}\.vtt$/;
beforeEach(() => {
r2.puts.length = 0;
r2.deletedKeys.length = 0;
r2.gets.length = 0;
});
function subtitlesUrl(videoId: string): string {
return `/api/videos/${videoId}/subtitles`;
}
function subtitleForm(input: {
content?: string;
fileName?: string;
versionId: string;
language?: string;
label?: string;
}): FormData {
const form = new FormData();
form.append(
'subtitle',
new File([input.content ?? SRT_FILE], input.fileName ?? 'cut.tr.srt', { type: 'text/plain' })
);
form.append('versionId', input.versionId);
if (input.language !== undefined) form.append('language', input.language);
if (input.label !== undefined) form.append('label', input.label);
return form;
}
function uploadRequest(videoId: string, form: FormData) {
return apiRequest(subtitlesUrl(videoId), {
rawBody: form,
// Constructing a Request from a FormData sets no Content-Length, and the route
// refuses a body it cannot size before it reads one.
headers: { 'content-length': '4096' },
});
}
/** An editor-owned bunny version with one Turkish track already uploaded. */
async function seedSubtitledVersion() {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr', label: 'Türkçe' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(201);
const subtitle = await readData<{ id: string; url: string }>(response);
return { ...scenario, subtitle };
}
// ---------------------------------------------------------------------------
// POST /api/videos/[videoId]/subtitles
// ---------------------------------------------------------------------------
describe('POST /api/videos/[videoId]/subtitles', () => {
it('stores the normalised WebVTT rather than the uploaded SRT', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'TR', label: ' Türkçe ' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(201);
const created = await readData<{ language: string; label: string; url: string }>(response);
expect(created.language).toBe('tr');
expect(created.label).toBe('Türkçe');
expect(created.url).toMatch(/^\/api\/upload\/subtitle\/[0-9a-f-]{36}\.vtt$/);
expect(r2.puts).toHaveLength(1);
expect(r2.puts[0].key).toMatch(SUBTITLE_KEY);
expect(r2.puts[0].body).toBe(NORMALIZED_VTT);
expect(r2.puts[0].contentType).toBe('text/vtt; charset=utf-8');
const row = await db.videoSubtitle.findFirstOrThrow({
where: { versionId: scenario.version.id },
});
expect(row.billedUserId).toBe(scenario.owner.id);
expect(row.uploadedByUserId).toBe(scenario.owner.id);
expect(Number(row.sizeBytes)).toBe(Buffer.byteLength(NORMALIZED_VTT));
});
it('leaves no upload reservation behind once the row is committed', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(await db.uploadReservation.count()).toBe(0);
});
it('replaces the track for a language instead of stacking a second one', async () => {
const scenario = await seedSubtitledVersion();
const firstKey = r2.puts[0].key;
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({
versionId: scenario.version.id,
language: 'tr',
label: 'Türkçe düzeltme',
content: ['1', '00:00:04,000 --> 00:00:05,000', 'Düzeltildi', '', ''].join('\n'),
})
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(201);
const rows = await db.videoSubtitle.findMany({ where: { versionId: scenario.version.id } });
expect(rows).toHaveLength(1);
expect(rows[0].label).toBe('Türkçe düzeltme');
// The object the replaced row pointed at is gone, so it cannot outlive its row.
expect(r2.deletedKeys).toEqual([firstKey]);
});
it('keeps a second language alongside the first', async () => {
const scenario = await seedSubtitledVersion();
await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'en', fileName: 'cut.en.srt' })
),
{ videoId: scenario.video.id }
);
const rows = await db.videoSubtitle.findMany({
where: { versionId: scenario.version.id },
orderBy: { language: 'asc' },
});
expect(rows.map((row) => row.language)).toEqual(['en', 'tr']);
});
it('refuses a file with no cues and stores nothing', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({
versionId: scenario.version.id,
language: 'tr',
content: 'just some prose\nand more of it\n',
})
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
expect(r2.puts).toHaveLength(0);
expect(await db.videoSubtitle.count()).toBe(0);
});
it('refuses a file that is not a subtitle by extension', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr', fileName: 'payload.html' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
expect(await readError(response)).toBe('Subtitle must be a .srt or .vtt file');
});
it('refuses a language that is not a tag', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: '<script>' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(400);
});
it('refuses a version that belongs to another video', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
signedInAs(scenario.owner);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: other.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(404);
expect(r2.puts).toHaveLength(0);
});
it('refuses a project COMMENTATOR, who may comment but not edit the cut', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
expect(r2.puts).toHaveLength(0);
});
it('refuses an anonymous caller', async () => {
const scenario = await seedVersion({ providerId: 'bunny' });
signedOut();
const response = await callRoute(
uploadSubtitle,
uploadRequest(
scenario.video.id,
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// GET /api/videos/[videoId]/subtitles
// ---------------------------------------------------------------------------
describe('GET /api/videos/[videoId]/subtitles', () => {
it('lists the tracks of one version and tells an editor they may manage them', async () => {
const scenario = await seedSubtitledVersion();
const response = await callRoute(
listSubtitles,
apiRequest(subtitlesUrl(scenario.video.id), {
searchParams: { versionId: scenario.version.id },
}),
{ videoId: scenario.video.id }
);
expect(response.status).toBe(200);
const data = await readData<{
subtitles: Array<{ language: string; canDelete: boolean }>;
canManageSubtitles: boolean;
}>(response);
expect(data.subtitles.map((subtitle) => subtitle.language)).toEqual(['tr']);
expect(data.canManageSubtitles).toBe(true);
expect(data.subtitles[0].canDelete).toBe(true);
});
it('shows a COMMENTATOR the tracks without the ability to manage them', async () => {
const scenario = await seedSubtitledVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
videoId: scenario.video.id,
});
expect(response.status).toBe(200);
const data = await readData<{
subtitles: Array<{ canDelete: boolean }>;
canManageSubtitles: boolean;
}>(response);
expect(data.subtitles).toHaveLength(1);
expect(data.canManageSubtitles).toBe(false);
expect(data.subtitles[0].canDelete).toBe(false);
});
it('refuses a signed-in stranger', async () => {
const scenario = await seedSubtitledVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
videoId: scenario.video.id,
});
expect(response.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
// ---------------------------------------------------------------------------
describe('DELETE /api/videos/[videoId]/subtitles/[subtitleId]', () => {
it('removes the row and the stored object', async () => {
const scenario = await seedSubtitledVersion();
const storedKey = r2.puts[0].key;
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
method: 'DELETE',
}),
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(200);
expect(await db.videoSubtitle.count()).toBe(0);
expect(r2.deletedKeys).toEqual([storedKey]);
});
it('refuses a COMMENTATOR and leaves the track in place', async () => {
const scenario = await seedSubtitledVersion();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
method: 'DELETE',
}),
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(403);
expect(await db.videoSubtitle.count()).toBe(1);
expect(r2.deletedKeys).toHaveLength(0);
});
it('answers 404 for a subtitle that belongs to another video', async () => {
const scenario = await seedSubtitledVersion();
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
signedInAs(scenario.owner);
const response = await callRoute(
deleteSubtitle,
apiRequest(`${subtitlesUrl(other.video.id)}/${scenario.subtitle.id}`, { method: 'DELETE' }),
{ videoId: other.video.id, subtitleId: scenario.subtitle.id }
);
expect(response.status).toBe(404);
expect(await db.videoSubtitle.count()).toBe(1);
});
});
// ---------------------------------------------------------------------------
// GET /api/upload/subtitle/[filename]
// ---------------------------------------------------------------------------
describe('GET /api/upload/subtitle/[filename]', () => {
function fileNameOf(url: string): string {
return url.slice('/api/upload/subtitle/'.length);
}
it('serves the stored WebVTT to a viewer', async () => {
const scenario = await seedSubtitledVersion();
const filename = fileNameOf(scenario.subtitle.url);
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
filename,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/vtt; charset=utf-8');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(await response.text()).toBe(NORMALIZED_VTT);
});
it('refuses a signed-in stranger without reading the object', async () => {
const scenario = await seedSubtitledVersion();
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
filename: fileNameOf(scenario.subtitle.url),
});
expect(response.status).toBe(403);
expect(r2.gets).toHaveLength(0);
});
it('rejects a filename that is not a stored subtitle', async () => {
const response = await callRoute(serveSubtitle, apiRequest('/api/upload/subtitle/x'), {
filename: '../../etc/passwd',
});
expect(response.status).toBe(400);
});
});
+1
View File
@@ -69,6 +69,7 @@ const REVIEWED_MIGRATIONS = [
'20260801120000_add_acquisition_analytics', '20260801120000_add_acquisition_analytics',
'20260818120000_add_upload_reservation_purpose', '20260818120000_add_upload_reservation_purpose',
'20260820120000_add_comment_images', '20260820120000_add_comment_images',
'20260822120000_add_video_subtitles',
]; ];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */ /** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
decodeSubtitleBuffer,
getSubtitleExtension,
MAX_SUBTITLE_CUES,
normalizeSubtitleFile,
normalizeSubtitleLanguage,
parseSubtitleCues,
sanitizeSubtitleLabel,
SAFE_SUBTITLE_PROXY_PATH,
serializeWebVtt,
subtitleProxyPathToObjectKey,
} from '@/lib/subtitle-validation';
const UUID = '11111111-2222-3333-4444-555555555555';
function encode(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
describe('getSubtitleExtension', () => {
it('accepts the two subtitle formats and nothing else', () => {
expect(getSubtitleExtension('cut.srt')).toBe('srt');
expect(getSubtitleExtension('cut.VTT')).toBe('vtt');
expect(getSubtitleExtension('cut.ass')).toBeNull();
expect(getSubtitleExtension('cut.srt.exe')).toBeNull();
});
});
describe('normalizeSubtitleLanguage', () => {
it('lowercases so a re-upload replaces the track it means to', () => {
expect(normalizeSubtitleLanguage('TR')).toBe('tr');
expect(normalizeSubtitleLanguage(' en-US ')).toBe('en-us');
expect(normalizeSubtitleLanguage('zh-Hant-TW')).toBe('zh-hant-tw');
});
it('rejects anything that is not a language tag', () => {
expect(normalizeSubtitleLanguage('')).toBeNull();
expect(normalizeSubtitleLanguage('t')).toBeNull();
expect(normalizeSubtitleLanguage('tr; drop table')).toBeNull();
expect(normalizeSubtitleLanguage('<script>')).toBeNull();
expect(normalizeSubtitleLanguage(42)).toBeNull();
});
});
describe('sanitizeSubtitleLabel', () => {
it('falls back when the label is empty after cleaning', () => {
expect(sanitizeSubtitleLabel(' ', 'TR')).toBe('TR');
expect(sanitizeSubtitleLabel(undefined, 'TR')).toBe('TR');
});
it('strips control characters and collapses whitespace', () => {
expect(sanitizeSubtitleLabel('Türk\u0000\n çe ', 'TR')).toBe('Türk çe');
});
it('caps the length', () => {
expect(sanitizeSubtitleLabel('a'.repeat(200), 'TR')).toHaveLength(60);
});
});
describe('decodeSubtitleBuffer', () => {
it('reads UTF-8 and drops the byte order mark', () => {
expect(decodeSubtitleBuffer(encode('\uFEFFmerhaba'))).toBe('merhaba');
});
it('falls back to a legacy codepage rather than rejecting the file', () => {
// 0xFD is "ı" in windows-1254 and not valid UTF-8 on its own.
const decoded = decodeSubtitleBuffer(new Uint8Array([0x61, 0xfd, 0x62]));
expect(decoded).not.toBeNull();
expect(decoded).toHaveLength(3);
});
});
describe('parseSubtitleCues', () => {
it('parses SRT, comma decimals and sequence numbers included', () => {
const cues = parseSubtitleCues(
[
'1',
'00:00:01,000 --> 00:00:02,500',
'Merhaba',
'',
'2',
'00:00:03,000 --> 00:00:04,000',
'Dünya',
'',
].join('\n')
);
expect(cues).toEqual([
{ start: 1, end: 2.5, text: 'Merhaba' },
{ start: 3, end: 4, text: 'Dünya' },
]);
});
it('parses WebVTT with cue ids, settings and short timestamps', () => {
const cues = parseSubtitleCues(
['WEBVTT', '', 'intro', '00:01.000 --> 00:02.000 align:start position:10%', 'Hello', ''].join(
'\n'
)
);
expect(cues).toEqual([{ start: 1, end: 2, text: 'Hello' }]);
});
it('skips NOTE, STYLE and REGION blocks', () => {
const cues = parseSubtitleCues(
[
'WEBVTT',
'',
'NOTE this is a comment',
'still the comment',
'',
'STYLE',
'::cue { color: red }',
'',
'00:00:01.000 --> 00:00:02.000',
'Kept',
'',
].join('\n')
);
expect(cues).toEqual([{ start: 1, end: 2, text: 'Kept' }]);
});
it('drops cues that end before they start and cues with no text', () => {
const cues = parseSubtitleCues(
[
'00:00:05,000 --> 00:00:02,000',
'Backwards',
'',
'00:00:06,000 --> 00:00:07,000',
'',
'00:00:08,000 --> 00:00:09,000',
'Good',
'',
].join('\n')
);
expect(cues).toEqual([{ start: 8, end: 9, text: 'Good' }]);
});
it('keeps known cue markup and removes everything else', () => {
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<i>tilt</i><script>alert(1)</script>{\\an8}', ''].join(
'\n'
)
);
expect(cues[0].text).toBe('<i>tilt</i>alert(1)');
});
it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => {
// Deleting `<b>` out of the middle would close the two halves into a `<script>` that
// was never written. Escaping what is left over is what stops that.
const cues = parseSubtitleCues(
['00:00:01,000 --> 00:00:02,000', '<scr<b>ipt>alert(1)', ''].join('\n')
);
expect(cues[0].text).toBe('&lt;scr<b>ipt&gt;alert(1)');
expect(cues[0].text).not.toContain('<script');
});
it('neutralises an arrow in cue text so the file cannot be re-split', () => {
const cues = parseSubtitleCues(['00:00:01,000 --> 00:00:02,000', 'a --> b', ''].join('\n'));
expect(cues[0].text).toBe('a --&gt; b');
expect(parseSubtitleCues(serializeWebVtt(cues))).toHaveLength(1);
});
it('stops at the cue ceiling', () => {
const lines: string[] = [];
for (let index = 0; index < MAX_SUBTITLE_CUES + 10; index += 1) {
lines.push(`00:00:0${index % 9}.000 --> 00:00:0${(index % 9) + 1}.000`, `line ${index}`, '');
}
expect(parseSubtitleCues(lines.join('\n'))).toHaveLength(MAX_SUBTITLE_CUES);
});
});
describe('normalizeSubtitleFile', () => {
it('converts SRT to a canonical WebVTT document', () => {
const result = normalizeSubtitleFile(
encode('1\r\n00:00:01,500 --> 00:00:02,000\r\nMerhaba\r\n\r\n')
);
expect(result).toEqual({
ok: true,
cueCount: 1,
vtt: 'WEBVTT\n\n00:00:01.500 --> 00:00:02.000\nMerhaba\n',
});
});
it('refuses an empty file', () => {
const result = normalizeSubtitleFile(new Uint8Array());
expect(result.ok).toBe(false);
});
it('refuses a file with no cues rather than storing an empty track', () => {
const result = normalizeSubtitleFile(encode('this is just prose\nand more prose\n'));
expect(result).toEqual({
ok: false,
error: 'No subtitle cues found. Upload a valid .srt or .vtt file.',
});
});
});
describe('subtitle proxy paths', () => {
it('only recognises a uuid .vtt path', () => {
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.vtt`)).toBe(true);
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.srt`)).toBe(false);
expect(SAFE_SUBTITLE_PROXY_PATH.test('/api/upload/subtitle/../../etc/passwd')).toBe(false);
});
it('maps a proxy path to its object key and refuses anything else', () => {
expect(subtitleProxyPathToObjectKey(`/api/upload/subtitle/${UUID}.vtt`)).toBe(
`subtitles/${UUID}.vtt`
);
expect(subtitleProxyPathToObjectKey(`/api/upload/image/${UUID}.png`)).toBeNull();
});
});
+10
View File
@@ -25,6 +25,16 @@ declare namespace YT {
getPlaybackRate(): number; getPlaybackRate(): number;
getAvailablePlaybackRates(): number[]; getAvailablePlaybackRates(): number[];
destroy(): void; destroy(): void;
// The module API is undocumented but is the only way to drive captions on a
// player embedded with controls=0, where YouTube's own CC button is hidden.
// `loadModule('captions')` turns them on, `unloadModule` turns them off, and
// `getOption('captions', 'tracklist')` answers only once the module has loaded
// and announced itself through onApiChange.
loadModule(moduleName: string): void;
unloadModule(moduleName: string): void;
setOption(module: string, option: string, value: unknown): void;
getOption<T = unknown>(module: string, option: string): T | undefined;
} }
interface PlayerOptions { interface PlayerOptions {