mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-17 17:18:35 +08:00
style(knowledge): render transcript timestamps as their own labels
Parse the transcript into timestamp/text cues so the time reads as a caption-sized label above each line instead of running inline with the sentence; lines without a leading timestamp continue the cue above, and text with no cue at all falls back to plain markdown. Also align the segmented control with the transcript inset, clear the AI dock with bottom padding, and round the player to 12px. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5af8ba1dd3
commit
84e7950723
@@ -402,7 +402,7 @@ export function MediaPlayer({ kind, src, allowDownload = false, onDownload }: Me
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
onDarkStage ? "bg-black" : "bg-fill-2",
|
||||
fullscreen ? "flex h-full w-full flex-col justify-center" : "rounded-[6px]",
|
||||
fullscreen ? "flex h-full w-full flex-col justify-center" : "rounded-[12px]",
|
||||
)}
|
||||
>
|
||||
{isVideo ? (
|
||||
@@ -437,7 +437,8 @@ export function MediaPlayer({ kind, src, allowDownload = false, onDownload }: Me
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 flex items-center gap-3 px-3 pb-2 pt-8",
|
||||
// pt keeps room for the scrim above the controls.
|
||||
"absolute inset-x-0 bottom-0 flex items-center gap-3 px-2 pb-2 pt-8",
|
||||
onDarkStage
|
||||
? "bg-gradient-to-t from-black/70 to-transparent text-white"
|
||||
: "text-text-2",
|
||||
|
||||
@@ -7,6 +7,7 @@ import rehypeHighlight from "rehype-highlight";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { KnowledgeFilePreview } from "~/api/knowledge";
|
||||
import { useLocalize } from "~/hooks";
|
||||
import { cn } from "~/utils";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { resolveKnowledgePreviewUrl } from "./previewUrlUtils";
|
||||
import { TopBar } from "./TopBar";
|
||||
@@ -65,9 +66,63 @@ function extractMarkdownSection(markdown: string, heading: string): string {
|
||||
return (nextHeading >= 0 ? rest.slice(0, nextHeading) : rest).trim();
|
||||
}
|
||||
|
||||
function MarkdownBody({ content }: { content: string }) {
|
||||
interface TranscriptCue {
|
||||
/** Raw timestamp label, e.g. "00:00:03 - 00:00:05". */
|
||||
time: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A cue line opens with its timestamp in brackets: "[00:00:03 - 00:00:05] …". */
|
||||
const CUE_PATTERN = /^\[(\d{1,2}:\d{2}(?::\d{2})?(?:\s*-\s*\d{1,2}:\d{2}(?::\d{2})?)?)\]\s*/;
|
||||
|
||||
/**
|
||||
* Split a transcript into timestamp/text pairs so the time can be rendered as its
|
||||
* own label instead of running inline with the sentence. Lines without a leading
|
||||
* timestamp continue the cue above them; text before the first cue is returned as
|
||||
* `preamble`. No cue found → the caller falls back to plain markdown.
|
||||
*/
|
||||
function parseTranscriptCues(markdown: string): { preamble: string; cues: TranscriptCue[] } {
|
||||
const cues: TranscriptCue[] = [];
|
||||
const preambleLines: string[] = [];
|
||||
|
||||
for (const rawLine of markdown.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(CUE_PATTERN);
|
||||
if (match) {
|
||||
cues.push({ time: match[1].replace(/\s*-\s*/, " - "), text: line.slice(match[0].length).trim() });
|
||||
} else if (cues.length) {
|
||||
const last = cues[cues.length - 1];
|
||||
last.text = last.text ? `${last.text} ${line}` : line;
|
||||
} else {
|
||||
preambleLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return { preamble: preambleLines.join("\n\n"), cues };
|
||||
}
|
||||
|
||||
/* Transcript markdown matches the cue text size. `.prose` (vendored typography CSS)
|
||||
hard-sets its own font-size from --markdown-font-size — the global chat font-size
|
||||
preference — and is emitted after the utilities, so the override needs `!`. */
|
||||
const TRANSCRIPT_MARKDOWN_SIZE = "!text-body";
|
||||
|
||||
function TranscriptCueList({ cues }: { cues: TranscriptCue[] }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-[#1d2129]">
|
||||
<ol className="space-y-4">
|
||||
{cues.map((cue, index) => (
|
||||
<li key={`${cue.time}-${index}`}>
|
||||
<div className="text-caption tabular-nums text-text-3">{cue.time}</div>
|
||||
<p className="mt-1 text-body text-text-1">{cue.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownBody({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn("prose prose-sm max-w-none text-[#1d2129]", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
|
||||
@@ -190,10 +245,13 @@ function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) {
|
||||
const entryText = extractMarkdownSection(content, "入库文本") || content;
|
||||
const recognizedText = extractMarkdownSection(content, "识别文本") || content;
|
||||
const activeContent = activeTab === "recognized" ? recognizedText : entryText;
|
||||
const { preamble, cues } = parseTranscriptCues(activeContent);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-white">
|
||||
<div className="flex shrink-0 items-center px-6 py-3">
|
||||
{/* 12px inset: the segmented control's own 3px padding lines its label
|
||||
up with the 16px-inset transcript text below. */}
|
||||
<div className="flex shrink-0 items-center px-3 pt-4">
|
||||
{/* Segmented control — mirrors the include/exclude tabs in
|
||||
Subscription/CreateChannel/FilterConditionEditor. */}
|
||||
<div className="flex flex-shrink-0 rounded-[6px] bg-[#F8F8F8] p-[3px]">
|
||||
@@ -224,8 +282,16 @@ function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) {
|
||||
) : error ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-[#86909c]">{error}</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-2">
|
||||
<MarkdownBody content={activeContent} />
|
||||
// pb clears the AI dock pinned at the bottom of the page.
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[114px] pt-3">
|
||||
{cues.length ? (
|
||||
<>
|
||||
{preamble ? <MarkdownBody content={preamble} className={TRANSCRIPT_MARKDOWN_SIZE} /> : null}
|
||||
<TranscriptCueList cues={cues} />
|
||||
</>
|
||||
) : (
|
||||
<MarkdownBody content={activeContent} className={TRANSCRIPT_MARKDOWN_SIZE} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -263,7 +329,7 @@ export function RichKnowledgePreview({
|
||||
{/* Side-by-side on md+: player left, transcript right, split by a single
|
||||
divider line (no card border/shadow). Stacked on narrow screens. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div className="shrink-0 px-6 py-5 md:w-1/2 md:overflow-y-auto">
|
||||
<div className="shrink-0 p-4 md:w-1/2 md:overflow-y-auto">
|
||||
<MediaPlayer
|
||||
kind={isVideo ? "video" : "audio"}
|
||||
src={mediaPlaybackUrl}
|
||||
|
||||
Reference in New Issue
Block a user