diff --git a/app/[category]/v/[videoId]/page.tsx b/app/[category]/v/[videoId]/page.tsx index 345bb62f..3a7dd12e 100644 --- a/app/[category]/v/[videoId]/page.tsx +++ b/app/[category]/v/[videoId]/page.tsx @@ -2,7 +2,10 @@ import IndexWithLang from './[lang]/page'; import type { VideoParams } from './[lang]/page'; -export { generateMetadata, dynamic, revalidate } from './[lang]/page'; +export { generateMetadata } from './[lang]/page'; + +export const dynamic = 'force-static'; +export const revalidate = 3600; export default async function NoLangVideo(props: {params: Promise}) { const params = await props.params; diff --git a/app/sitemap.ts b/app/sitemap.ts index 125ab85d..678f142a 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -13,7 +13,7 @@ type SiteMapEntry = { }; function buildUrl(relativePath): string { - return `${Constants.SITE_ROOT_URL}/${relativePath}`; + return `${Constants.SITE_ROOT_URL}${relativePath}`; } export default async function sitemap(): Promise { diff --git a/common/transcript.ts b/common/transcript.ts index 30db2798..a76faca4 100644 --- a/common/transcript.ts +++ b/common/transcript.ts @@ -104,6 +104,7 @@ export class DiarizedTranscript { // Wait for everything. const [transcriptDataResult, ...sentenceTableResult] = await Promise.allSettled(loadPromises); + console.log("Reading from Transcript " + videoId); if (transcriptDataResult.status === 'rejected') { return new DiarizedTranscript(category, videoId, EMPTY_TRASCRIPT_DATA, {}, ["Cannout load transcript Data"]); @@ -330,7 +331,7 @@ function toSentences(speaker : SpeakerId, firstId : number, words : string[], wo function tsvToSentenceTable(tsv : string) : SentenceTable { const sentenceTableRows : [string, string][] = - parse(tsv, { delimiter: '\t', trim: true }); + parse(tsv, { delimiter: '\t', trim: true }) as [string, string][]; const sentenceTable : SentenceTable = {}; sentenceTableRows.forEach(row => sentenceTable[row[0]] = row[1]); return sentenceTable; diff --git a/common/whisperx.ts b/common/whisperx.ts index 6c2e2b3d..17c84a6f 100644 --- a/common/whisperx.ts +++ b/common/whisperx.ts @@ -46,7 +46,11 @@ export function makeWhisperXTranscriptsPath( category: CategoryId, videoId: VideoId, language: Iso6393Code): string { - const iso6391Code = langs.where('3', language)['1']; + const iso6391Code = langs.where('3', language)?.['1']; + if (!iso6391Code) { + throw new Error(`Unsupported language code: ${language}`); + } + return makePublicPath( category, Constants.WHISPERX_ARCHIVE_SUBDIR, diff --git a/components/SearchResult.tsx b/components/SearchResult.tsx index 351b5aeb..5d1c50f3 100644 --- a/components/SearchResult.tsx +++ b/components/SearchResult.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as Constants from 'config/constants'; import Box from '@mui/material/Box'; import Divider from '@mui/material/Divider'; diff --git a/components/SpeakerBubble.tsx b/components/SpeakerBubble.tsx index 56cdaa1b..d8a1fc4d 100644 --- a/components/SpeakerBubble.tsx +++ b/components/SpeakerBubble.tsx @@ -4,13 +4,15 @@ import { toSpeakerColorClass } from 'utilities/client/css'; type SpeakerBubbleParams = { speakerNum : number; + start: number; + end: number; children: React.ReactNode[]; }; -export default function SpeakerBubble({speakerNum, children} : SpeakerBubbleParams) { +export default function SpeakerBubble({speakerNum, start, end, children} : SpeakerBubbleParams) { return ( - + { children } ); diff --git a/components/SpeakerBubbleTitle.tsx b/components/SpeakerBubbleTitle.tsx index cf8bc07c..868c262c 100644 --- a/components/SpeakerBubbleTitle.tsx +++ b/components/SpeakerBubbleTitle.tsx @@ -13,9 +13,24 @@ import { useContext } from 'react' type SpeakerBubbleTitleParams = { speakerNum : number; + start: number; + end: number; }; -export default function SpeakerBubbleTitle({speakerNum} : SpeakerBubbleTitleParams) { +function formatTime(totalSeconds : number) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (totalSeconds < 60) { + return `${seconds.toFixed(0)}s`; + } + + // Format as MM:SS + return `${String(minutes)}m${String(seconds.toFixed(0)).padStart(2, '0')}s` +} + +export default function SpeakerBubbleTitle({speakerNum, start, end} : SpeakerBubbleTitleParams) { + const durationSecs = end - start; const annotationsContext = useAnnotations(); const { name, tags } = getSpeakerAttributes(speakerNum, annotationsContext.speakerInfo); @@ -47,6 +62,12 @@ export default function SpeakerBubbleTitle({speakerNum} : SpeakerBubbleTitlePara }) } + + [{formatTime(durationSecs)}] + { speakerNums.add(bubble.speaker); + let bubbleStart = Number.MAX_VALUE; + let bubbleEnd = 0; + const sentenceElements = new Array; + for (const si of bubble.sentenceInfo) { + const [segmentId, speakerId, start, end] = si; + bubbleStart = Math.min(bubbleStart, start); + bubbleEnd = Math.max(bubbleEnd, end); + + sentenceElements.push( +

+ { textLines( segmentId, languageOrder, diarizedTranscript) } +

+ ); + } return ( - - { - bubble.sentenceInfo.map(([segmentId, speakerId, start]) => ( -

- { textLines( segmentId, languageOrder, diarizedTranscript) } -

- )) - } + + {sentenceElements} ); }); diff --git a/components/TranscriptControlBar.tsx b/components/TranscriptControlBar.tsx index ce36b5e4..b4b2ef14 100644 --- a/components/TranscriptControlBar.tsx +++ b/components/TranscriptControlBar.tsx @@ -6,11 +6,14 @@ import FormGroup from '@mui/material/FormGroup'; import IconButton from '@mui/material/IconButton'; import LanguageNav from 'components/LanguageNav'; import Paper from '@mui/material/Paper'; +import DownloadIcon from '@mui/icons-material/Download'; import PublishIcon from '@mui/icons-material/Publish'; import Stack from '@mui/material/Stack'; import Switch from '@mui/material/Switch'; import Tooltip from '@mui/material/Tooltip'; +import { fromTimeClassName } from 'utilities/client/css'; import { useContext, useState } from 'react'; +import { stringify } from 'csv-stringify/browser/esm/sync' import { VideoControlContext } from 'components/providers/VideoControlProvider'; import { useActionDialog } from 'components/providers/ActionDialogProvider' import { useAnnotations } from 'components/providers/AnnotationsProvider' @@ -24,6 +27,86 @@ type TranscriptControlBarProps = { sx?: SxProps; }; +type CsvEntry = { + start: number; + end: number; + speakerName: string; + speakerTags: string; + sentences: string; +}; + +function downloadTranscriptCsv(csvContent, fileName) { + // Create a Blob with the CSV data and type + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + + // Create a URL for the Blob + const url = URL.createObjectURL(blob); + + // Create an anchor tag for downloading + const a = document.createElement('a'); + a.href = url; + a.download = fileName || 'download.csv'; // Set the file name + + // Append the anchor to the body, click it, and remove it + document.body.appendChild(a); // Required for Firefox + a.click(); + document.body.removeChild(a); + + // Revoke the object URL to free up memory (optional but good practice) + URL.revokeObjectURL(url); +} + +function extractStartEnd(headerSection) : [number, number] { + const timingElement = headerSection.getElementsByClassName('d')[0]; + if (timingElement instanceof HTMLElement) { + const timing = timingElement.dataset['timing']; + if (timing) { + const splits = timing.split('-').map(x => parseInt(x)); + if (splits.length === 2) { + return [splits[0], splits[1]]; + } + } + } + + return [-1, -1]; +} + +function generateTranscriptCsv() { + const entries = new Array; + + const speakerBubbles = document.getElementsByTagName('main')[0].getElementsByTagName('article'); + for (const bubble of speakerBubbles) { + // Parse the headers. + const headerSection = bubble.firstElementChild; + if (!headerSection) { + continue; + } + const speakerName = headerSection.getElementsByTagName('h5')[0]?.textContent ?? "unknown"; + const [start, end] = extractStartEnd(headerSection); + + const tagList = new Array; + for (const s of headerSection.getElementsByTagName('span')) { + tagList.push(s.textContent); + } + + // Parse the sentences. First paragraph is buttons. + const paragraphs = bubble.getElementsByTagName('p') ?? []; + const segments = new Array; + for (let i = 1; i < paragraphs.length; i++) { + segments.push(paragraphs[i].textContent); + } + entries.push({ + start, + end, + speakerName, + speakerTags: tagList.join(', '), + sentences: segments.join('\n'), + }); + } + + return stringify(entries, {header: true}); +} + export default function TranscriptControlBar( { curLang, sx = [] }: TranscriptControlBarProps) { const [ autoscroll, setAutoscroll ] = useState(true); @@ -77,6 +160,21 @@ export default function TranscriptControlBar( curLang={curLang} sx={{width: "100%"}} /> + + + + +