mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
add caption match score
Subtract from 100% if the caption dialogue overlaps with a TIDB provided credit sequence
This commit is contained in:
parent
7de37b9613
commit
41bd5cc4b7
3 changed files with 154 additions and 48 deletions
|
|
@ -27,6 +27,8 @@ import {
|
|||
sortLangCodes,
|
||||
} from "@/utils/language";
|
||||
|
||||
import { useCaptionMatchScore } from "../../hooks/useCaptionMatchScore";
|
||||
|
||||
/* eslint-disable react/no-unused-prop-types */
|
||||
export interface CaptionOptionProps {
|
||||
countryCode?: string;
|
||||
|
|
@ -41,12 +43,12 @@ export interface CaptionOptionProps {
|
|||
isTranslatedTarget?: boolean;
|
||||
subtitleUrl?: string;
|
||||
subtitleType?: string;
|
||||
// subtitle details from wyzie
|
||||
subtitleSource?: string;
|
||||
subtitleEncoding?: string;
|
||||
isHearingImpaired?: boolean;
|
||||
onDoubleClick?: () => void;
|
||||
onTranslate?: () => void;
|
||||
matchScore?: number | null;
|
||||
}
|
||||
/* eslint-enable react/no-unused-prop-types */
|
||||
|
||||
|
|
@ -128,12 +130,17 @@ export function CaptionOption(props: CaptionOptionProps) {
|
|||
parts.push(`URL: ${props.subtitleUrl}`);
|
||||
}
|
||||
|
||||
if (props.matchScore !== undefined && props.matchScore !== null) {
|
||||
parts.push(`Match Score: ${props.matchScore}%`);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}, [
|
||||
props.subtitleUrl,
|
||||
props.subtitleSource,
|
||||
props.subtitleEncoding,
|
||||
props.isHearingImpaired,
|
||||
props.matchScore,
|
||||
]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
|
|
@ -176,45 +183,64 @@ export function CaptionOption(props: CaptionOptionProps) {
|
|||
>
|
||||
<span
|
||||
data-active-link={props.selected ? true : undefined}
|
||||
className="flex items-center"
|
||||
className="flex flex-col items-start"
|
||||
>
|
||||
{props.flag ? (
|
||||
<span data-code={props.countryCode} className="mr-3 inline-flex">
|
||||
<FlagIcon langCode={props.countryCode} />
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
props.flag || props.subtitleUrl || props.subtitleSource
|
||||
? "truncate max-w-[100px]"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{props.subtitleType && (
|
||||
<span className="ml-2 px-2 py-0.5 rounded bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-xs font-semibold">
|
||||
{props.subtitleType.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{props.subtitleSource && (
|
||||
<div className="flex items-center">
|
||||
{props.flag ? (
|
||||
<span data-code={props.countryCode} className="mr-3 inline-flex">
|
||||
<FlagIcon langCode={props.countryCode} />
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={classNames(
|
||||
"ml-2 px-2 py-0.5 rounded text-white text-xs font-semibold overflow-hidden text-ellipsis whitespace-nowrap",
|
||||
{
|
||||
"bg-blue-500": props.subtitleSource.includes("wyzie"),
|
||||
"bg-orange-500": props.subtitleSource === "opensubs",
|
||||
"bg-purple-500": props.subtitleSource === "febbox",
|
||||
"bg-green-500": props.subtitleSource === "granite",
|
||||
},
|
||||
)}
|
||||
className={
|
||||
props.flag || props.subtitleUrl || props.subtitleSource
|
||||
? "truncate max-w-[100px]"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{props.subtitleSource.toUpperCase()}
|
||||
{props.children}
|
||||
</span>
|
||||
)}
|
||||
{props.isHearingImpaired && (
|
||||
<Icon icon={Icons.EAR} className="ml-2" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{props.subtitleType && (
|
||||
<span className="px-2 py-0.5 mt-2 rounded bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-xs font-semibold">
|
||||
{props.subtitleType.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{props.subtitleSource && (
|
||||
<span
|
||||
className={classNames(
|
||||
"ml-2 px-2 py-0.5 mt-2 rounded text-white text-xs font-semibold overflow-hidden text-ellipsis whitespace-nowrap",
|
||||
{
|
||||
"bg-blue-500": props.subtitleSource.includes("wyzie"),
|
||||
"bg-orange-500": props.subtitleSource === "opensubs",
|
||||
"bg-purple-500": props.subtitleSource === "febbox",
|
||||
"bg-green-500": props.subtitleSource === "granite",
|
||||
},
|
||||
)}
|
||||
>
|
||||
{props.subtitleSource.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{props.isHearingImpaired && (
|
||||
<Icon icon={Icons.EAR} className="ml-2 mt-2" />
|
||||
)}
|
||||
{props.matchScore !== undefined && props.matchScore !== null && (
|
||||
<span
|
||||
className={classNames(
|
||||
"text-xs font-bold ml-2 mt-2 whitespace-nowrap",
|
||||
{
|
||||
"text-video-context-type-accent": props.matchScore >= 80,
|
||||
"text-yellow-500":
|
||||
props.matchScore >= 50 && props.matchScore < 80,
|
||||
"text-video-context-error": props.matchScore < 50,
|
||||
},
|
||||
)}
|
||||
>
|
||||
~{props.matchScore}% match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</SelectableLink>
|
||||
{tooltipContent && showTooltip && (
|
||||
|
|
@ -420,7 +446,7 @@ export function CaptionsView({
|
|||
}: CaptionsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useOverlayRouter(id);
|
||||
const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id);
|
||||
const selectedCaption = usePlayerStore((s) => s.caption.selected);
|
||||
const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask);
|
||||
const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions();
|
||||
const [isRandomSelecting, setIsRandomSelecting] = useState(false);
|
||||
|
|
@ -447,6 +473,7 @@ export function CaptionsView({
|
|||
const delay = useSubtitleStore((s) => s.delay);
|
||||
const appLanguage = useLanguageStore((s) => s.language);
|
||||
const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs);
|
||||
const matchScore = useCaptionMatchScore();
|
||||
|
||||
// Get combined caption list
|
||||
const captions = useMemo(
|
||||
|
|
@ -512,13 +539,13 @@ export function CaptionsView({
|
|||
|
||||
// Get current subtitle text preview
|
||||
const currentSubtitleText = useMemo(() => {
|
||||
if (!srtData || !selectedCaptionId) return null;
|
||||
if (!srtData || !selectedCaption) return null;
|
||||
const parsedCaptions = parseSubtitles(srtData, selectedLanguage);
|
||||
const visibleCaption = parsedCaptions.find(({ start, end }) =>
|
||||
captionIsVisible(start, end, delay, videoTime),
|
||||
);
|
||||
return visibleCaption?.content;
|
||||
}, [srtData, selectedLanguage, delay, videoTime, selectedCaptionId]);
|
||||
}, [srtData, selectedLanguage, delay, videoTime, selectedCaption]);
|
||||
|
||||
function onDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
|
|
@ -614,7 +641,7 @@ export function CaptionsView({
|
|||
onDrop={(event) => onDrop(event)}
|
||||
>
|
||||
{/* Current subtitle preview */}
|
||||
{selectedCaptionId && (
|
||||
{selectedCaption && (
|
||||
<div className="mt-3 p-2 rounded-xl bg-video-context-light bg-opacity-10 text-center sm:hidden">
|
||||
<div className="text-sm text-video-context-type-secondary mb-1">
|
||||
{t("player.menus.subtitles.previewLabel")}
|
||||
|
|
@ -641,10 +668,7 @@ export function CaptionsView({
|
|||
|
||||
<Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3">
|
||||
{/* Off button */}
|
||||
<CaptionOption
|
||||
onClick={() => disable()}
|
||||
selected={!selectedCaptionId}
|
||||
>
|
||||
<CaptionOption onClick={() => disable()} selected={!selectedCaption}>
|
||||
{t("player.menus.subtitles.offChoice")}
|
||||
</CaptionOption>
|
||||
|
||||
|
|
@ -652,16 +676,30 @@ export function CaptionsView({
|
|||
{captions.length > 0 && (
|
||||
<CaptionOption
|
||||
onClick={() => handleRandomSelect()}
|
||||
selected={!!selectedCaptionId}
|
||||
selected={!!selectedCaption}
|
||||
loading={isRandomSelecting}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{t("player.menus.subtitles.autoSelectChoice")}
|
||||
{selectedCaptionId && (
|
||||
{selectedCaption && (
|
||||
<span className="text-video-context-type-secondary text-xs">
|
||||
{t("player.menus.subtitles.autoSelectDifferentChoice")}
|
||||
</span>
|
||||
)}
|
||||
{matchScore !== undefined && matchScore !== null && (
|
||||
<span
|
||||
className={classNames(
|
||||
"text-xs font-bold mt-2 whitespace-nowrap",
|
||||
{
|
||||
"text-video-context-type-accent": matchScore >= 80,
|
||||
"text-yellow-500": matchScore >= 50 && matchScore < 80,
|
||||
"text-video-context-error": matchScore < 50,
|
||||
},
|
||||
)}
|
||||
>
|
||||
~{matchScore}% match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CaptionOption>
|
||||
)}
|
||||
|
|
@ -671,10 +709,10 @@ export function CaptionsView({
|
|||
|
||||
{/* Paste subtitle option */}
|
||||
<PasteCaptionOption
|
||||
selected={selectedCaptionId === "pasted-caption"}
|
||||
selected={selectedCaption?.id === "pasted-caption"}
|
||||
/>
|
||||
|
||||
{selectedCaptionId && (
|
||||
{selectedCaption && (
|
||||
<Menu.ChevronLink
|
||||
onClick={() => router.navigate("/captions/transcript")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { usePlayerStore } from "@/stores/player/store";
|
|||
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
||||
|
||||
import { CaptionOption } from "./CaptionsView";
|
||||
import { useCaptionMatchScore } from "../../hooks/useCaptionMatchScore";
|
||||
|
||||
export interface LanguageSubtitlesViewProps {
|
||||
id: string;
|
||||
|
|
@ -36,6 +37,7 @@ export function LanguageSubtitlesView({
|
|||
>(null);
|
||||
const [scrollTrigger, setScrollTrigger] = useState(0);
|
||||
const captionList = usePlayerStore((s) => s.captionList);
|
||||
const matchScore = useCaptionMatchScore();
|
||||
|
||||
// Trigger scroll when selected caption changes
|
||||
useEffect(() => {
|
||||
|
|
@ -175,6 +177,7 @@ export function LanguageSubtitlesView({
|
|||
subtitleSource={v.source}
|
||||
subtitleEncoding={v.encoding}
|
||||
isHearingImpaired={v.isHearingImpaired}
|
||||
matchScore={v.id === selectedCaptionId ? matchScore : undefined}
|
||||
>
|
||||
{v.display || v.id}
|
||||
</CaptionOption>
|
||||
|
|
|
|||
65
src/components/player/hooks/useCaptionMatchScore.ts
Normal file
65
src/components/player/hooks/useCaptionMatchScore.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
import { useSkipTime } from "@/components/player/hooks/useSkipTime";
|
||||
import { parseSubtitles } from "@/components/player/utils/captions";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
export function useCaptionMatchScore() {
|
||||
const segments = useSkipTime();
|
||||
const videoDuration = usePlayerStore((s) => s.progress.duration);
|
||||
const srtData = usePlayerStore((s) => s.caption.selected?.srtData);
|
||||
|
||||
const matchScore = useMemo(() => {
|
||||
if (!srtData || !segments.length) return null;
|
||||
const credits = segments.find((s) => s.type === "credits");
|
||||
if (!credits || !credits.start_ms) return null;
|
||||
|
||||
const startMs = credits.start_ms;
|
||||
const endMs = credits.end_ms ?? videoDuration * 1000;
|
||||
const durationMs = endMs - startMs;
|
||||
|
||||
if (durationMs <= 0) return null;
|
||||
|
||||
const cues = parseSubtitles(srtData);
|
||||
const intervals: [number, number][] = [];
|
||||
|
||||
cues.forEach((cue) => {
|
||||
const cueStart = cue.start;
|
||||
const cueEnd = cue.end;
|
||||
|
||||
const overlapStart = Math.max(startMs, cueStart);
|
||||
const overlapEnd = Math.min(endMs, cueEnd);
|
||||
|
||||
if (overlapEnd > overlapStart) {
|
||||
intervals.push([overlapStart, overlapEnd]);
|
||||
}
|
||||
});
|
||||
|
||||
if (intervals.length === 0) return 100;
|
||||
|
||||
intervals.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const merged: [number, number][] = [];
|
||||
let current = intervals[0];
|
||||
|
||||
for (let i = 1; i < intervals.length; i += 1) {
|
||||
const next = intervals[i];
|
||||
if (next[0] <= current[1]) {
|
||||
current[1] = Math.max(current[1], next[1]);
|
||||
} else {
|
||||
merged.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
merged.push(current);
|
||||
|
||||
const overlapMs = merged.reduce(
|
||||
(acc, range) => acc + (range[1] - range[0]),
|
||||
0,
|
||||
);
|
||||
const percentage = (overlapMs / durationMs) * 100;
|
||||
return Math.round(100 - percentage);
|
||||
}, [srtData, segments, videoDuration]);
|
||||
|
||||
return matchScore;
|
||||
}
|
||||
Loading…
Reference in a new issue