[Update] Support ASS format file. and show actor combine with text.

This commit is contained in:
Sakamoto Shiina
2025-02-12 18:32:31 +09:00
parent 65ee75b49e
commit 31deeae53f

View File

@@ -14,8 +14,8 @@ export const PluginsSection = () => {
const [targetHour, setTargetHour] = useState("19"); const [targetHour, setTargetHour] = useState("19");
const [targetMinute, setTargetMinute] = useState("00"); const [targetMinute, setTargetMinute] = useState("00");
// カウントダウン状態 // カウントダウン状態
// initialCountdown: 開始ボタン押下時に算される元の残り秒数 // initialCountdown: 再生開始ボタン押下時に算される元の残り秒数
const [initialCountdown, setInitialCountdown] = useState(null); const [initialCountdown, setInitialCountdown] = useState(null);
// countdownAdjustment: ユーザーが上下ボタンで調整する値(秒単位) // countdownAdjustment: ユーザーが上下ボタンで調整する値(秒単位)
const [countdownAdjustment, setCountdownAdjustment] = useState(0); const [countdownAdjustment, setCountdownAdjustment] = useState(0);
@@ -24,12 +24,12 @@ export const PluginsSection = () => {
// cuesScheduled: 字幕タイマーが一度スケジュールされたか // cuesScheduled: 字幕タイマーが一度スケジュールされたか
const [cuesScheduled, setCuesScheduled] = useState(false); const [cuesScheduled, setCuesScheduled] = useState(false);
// setTimeout/setInterval のタイマーID管理用 // タイマー(setTimeout/setInterval)のID管理用
const timersRef = useRef([]); const timersRef = useRef([]);
// ファイル入力リセット用の ref // ファイル入力リセット用の ref
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// ファイルアップロード処理 // ファイルアップロード時の処理
const handleFileUpload = (event) => { const handleFileUpload = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -37,9 +37,15 @@ export const PluginsSection = () => {
reader.onload = (e) => { reader.onload = (e) => {
const content = e.target.result; const content = e.target.result;
setSrtContent(content); setSrtContent(content);
const parsedCues = parseSRT(content); let parsedCues = [];
// 拡張子により ASS と SRT を判定
if (file.name.toLowerCase().endsWith(".ass")) {
parsedCues = parseASS(content);
} else {
parsedCues = parseSRT(content);
}
setCues(parsedCues); setCues(parsedCues);
console.log("パース結果:", parsedCues); console.log("Parsed cues:", parsedCues);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
@@ -49,14 +55,20 @@ export const PluginsSection = () => {
// 字幕開始時の処理 // 字幕開始時の処理
const startFunction = (cue) => { const startFunction = (cue) => {
console.log(`字幕開始 (index: ${cue.index}): ${cue.text}`); let send_text = "";
sendTextToOverlay(cue.text); if (cue.actor !== "") {
send_text = `[${cue.actor}] ${cue.text}`;
} else {
send_text = `${cue.text}`;
}
console.log(`字幕開始 (index: ${cue.index}) send_text:${send_text}`);
sendTextToOverlay(send_text);
}; };
// 字幕終了時の処理 // 字幕終了時の処理
const endFunction = (cue) => { const endFunction = (cue) => {
console.log(`字幕終了 (index: ${cue.index}): ${cue.text}`); console.log(`字幕終了 (index: ${cue.index}): ${cue.text}`);
// 必要に応じ終了処理(例:テキストクリア) // 必要に応じ終了処理(例:テキストクリア)を実装可能
// sendTextToOverlay(""); // sendTextToOverlay("");
}; };
@@ -75,7 +87,7 @@ export const PluginsSection = () => {
setCuesScheduled(false); setCuesScheduled(false);
}; };
// cues のスケジュールを行う(offset は countdownAdjustment * 1000 // cues のスケジュールを行う(字幕開始時のオフセットは countdownAdjustment * 1000
const scheduleCues = (offset) => { const scheduleCues = (offset) => {
cues.forEach((cue) => { cues.forEach((cue) => {
const startDelay = cue.startTime * 1000 + offset; const startDelay = cue.startTime * 1000 + offset;
@@ -93,7 +105,6 @@ export const PluginsSection = () => {
// カウントダウンタイマーの開始 // カウントダウンタイマーの開始
const startCountdownInterval = (initialValue) => { const startCountdownInterval = (initialValue) => {
// 初期表示は (initialValue + countdownAdjustment)
setEffectiveCountdown(initialValue + countdownAdjustment); setEffectiveCountdown(initialValue + countdownAdjustment);
const countdownInterval = setInterval(() => { const countdownInterval = setInterval(() => {
setEffectiveCountdown((prev) => { setEffectiveCountdown((prev) => {
@@ -150,7 +161,8 @@ export const PluginsSection = () => {
) { ) {
sendTextToOverlay("Start."); sendTextToOverlay("Start.");
console.log("Start."); console.log("Start.");
scheduleCues(0); // オフセットは countdownAdjustment × 1000 を字幕に反映
scheduleCues(countdownAdjustment * 1000);
setCuesScheduled(true); setCuesScheduled(true);
} }
}, [effectiveCountdown, isPlaying, cuesScheduled, countdownAdjustment]); }, [effectiveCountdown, isPlaying, cuesScheduled, countdownAdjustment]);
@@ -190,11 +202,11 @@ export const PluginsSection = () => {
<h1>字幕プレイヤー</h1> <h1>字幕プレイヤー</h1>
<div className={styles.fileSection}> <div className={styles.fileSection}>
<label className={styles.label}> <label className={styles.label}>
SRTファイルを選択: SRT/ASSファイルを選択:
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".srt" accept=".srt,.ass"
onChange={handleFileUpload} onChange={handleFileUpload}
className={styles.input} className={styles.input}
/> />
@@ -217,9 +229,7 @@ export const PluginsSection = () => {
</label> </label>
{playbackMode === "absolute" && ( {playbackMode === "absolute" && (
<div className={styles.timeSection}> <div className={styles.timeSection}>
<label className={styles.label}> <label className={styles.label}>再生開始時刻 (HH:MM):</label>
再生開始時刻 (HH:MM):
</label>
<div className={styles.timeSelects}> <div className={styles.timeSelects}>
<select <select
value={targetHour} value={targetHour}
@@ -265,21 +275,29 @@ export const PluginsSection = () => {
停止 停止
</button> </button>
</div> </div>
<div className={styles.countdown}> {/* カウントダウン表示:字幕開始前は常に表示 */}
<span>カウントダウン: {effectiveCountdown} </span> {effectiveCountdown !== null && !cuesScheduled && (
<button <div className={styles.countdown}>
onClick={() => setEffectiveCountdown((prev) => prev + 1)} <span>カウントダウン: {effectiveCountdown} </span>
className={styles.adjustButton} <button
> onClick={() =>
setEffectiveCountdown((prev) => prev + 1)
</button> }
<button className={styles.adjustButton}
onClick={() => setEffectiveCountdown((prev) => prev - 1)} >
className={styles.adjustButton}
> </button>
<button
</button> onClick={() =>
</div> setEffectiveCountdown((prev) => prev - 1)
}
className={styles.adjustButton}
>
</button>
</div>
)}
{/* 字幕一覧の表示relative モードの場合、クリックでジャンプ) */}
{cues.length > 0 && ( {cues.length > 0 && (
<div className={styles.subtitleSection}> <div className={styles.subtitleSection}>
<h2>字幕一覧</h2> <h2>字幕一覧</h2>
@@ -289,6 +307,7 @@ export const PluginsSection = () => {
<th>番号</th> <th>番号</th>
<th>開始</th> <th>開始</th>
<th>終了</th> <th>終了</th>
<th>Actor</th>
<th>テキスト</th> <th>テキスト</th>
</tr> </tr>
</thead> </thead>
@@ -302,6 +321,7 @@ export const PluginsSection = () => {
<td>{cue.index}</td> <td>{cue.index}</td>
<td>{formatTime(cue.startTime)}</td> <td>{formatTime(cue.startTime)}</td>
<td>{formatTime(cue.endTime)}</td> <td>{formatTime(cue.endTime)}</td>
<td>{cue.actor}</td>
<td>{cue.text}</td> <td>{cue.text}</td>
</tr> </tr>
))} ))}
@@ -318,7 +338,8 @@ export const PluginsSection = () => {
/** /**
* SRT形式の文字列を解析する関数 * SRT形式の文字列を解析する関数
* ユーザー提示のサンプルに基づき、改行コードを正規化、空行で分割して解析 * 改行コードを正規化、空行で分割して解析する
* actor は存在しないため、空文字列をセット)
*/ */
const parseSRT = (data) => { const parseSRT = (data) => {
const cues = []; const cues = [];
@@ -333,14 +354,54 @@ const parseSRT = (data) => {
const start = parseTime(timeMatch[1]); const start = parseTime(timeMatch[1]);
const end = parseTime(timeMatch[2]); const end = parseTime(timeMatch[2]);
const text = lines.slice(2).join("\n"); const text = lines.slice(2).join("\n");
cues.push({ index, startTime: start, endTime: end, text }); cues.push({ index, startTime: start, endTime: end, actor: "", text });
} }
}); });
return cues; return cues;
}; };
/** /**
* "HH:MM:SS,mmm" 形式の文字列を秒数に変換する関数 * ASS形式の文字列を解析する関数
* [Events] セクション内の "Dialogue:" 行から、
* フォーマット "Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
* に沿って分割する。
* ここでは Name を actor、Text を text として抽出する。
*/
const parseASS = (data) => {
const cues = [];
const lines = data.split(/\r?\n/);
let index = 1;
lines.forEach((line) => {
if (line.startsWith("Dialogue:")) {
const dialogueLine = line.substring("Dialogue:".length).trim();
const parts = dialogueLine.split(",");
// parts[0]: Layer, parts[1]: Start, parts[2]: End, parts[3]: Style, parts[4]: Name, parts[5]: MarginL, parts[6]: MarginR, parts[7]: MarginV, parts[8]: Effect, parts[9]: Text
if (parts.length < 10) return;
const startTime = parseASSTime(parts[1].trim());
const endTime = parseASSTime(parts[2].trim());
const actor = parts[4].trim();
const text = parts.slice(9).join(",").trim();
cues.push({ index: index++, startTime, endTime, actor, text });
}
});
return cues;
};
/**
* "H:MM:SS.cc" 形式の ASS 時刻文字列を秒数に変換する関数
* 例: "0:00:10.52" → 10.52 秒
*/
const parseASSTime = (timeString) => {
const parts = timeString.split(":");
if (parts.length !== 3) return 0;
const hours = parseFloat(parts[0]);
const minutes = parseFloat(parts[1]);
const seconds = parseFloat(parts[2]);
return hours * 3600 + minutes * 60 + seconds;
};
/**
* "HH:MM:SS,mmm" 形式の SRT 時刻文字列を秒数に変換する関数
*/ */
const parseTime = (timeString) => { const parseTime = (timeString) => {
const [hms, ms] = timeString.split(","); const [hms, ms] = timeString.split(",");