diff --git a/__pycache__/image_optimizer.cpython-313.pyc b/__pycache__/image_optimizer.cpython-313.pyc new file mode 100644 index 000000000..2f6e3fe86 Binary files /dev/null and b/__pycache__/image_optimizer.cpython-313.pyc differ diff --git a/compress_gif.py b/compress_gif.py new file mode 100755 index 000000000..992b48086 --- /dev/null +++ b/compress_gif.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import sys +import subprocess + +def compress_gif(input_gif: str, output_gif: str, optimize_level=3, lossy=100, colors=128): + """ + gifsicle コマンドを使って GIF を圧縮する + :param input_gif: 入力 GIF ファイルパス + :param output_gif: 出力 GIF ファイルパス + :param optimize_level: 最適化レベル (1~3) + :param lossy: lossy 圧縮の強度 (0~100) + :param colors: 使用する色数 (1~256) + """ + command = [ + "gifsicle", + f"--optimize={optimize_level}", + f"--lossy={lossy}", + f"--colors={colors}", + "-o", + output_gif, + input_gif + ] + subprocess.run(command, check=True) + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python compress_gif.py ") + sys.exit(1) + input_gif_path = sys.argv[1] + output_gif_path = sys.argv[2] + + compress_gif(input_gif_path, output_gif_path) + print(f"Compressed GIF saved to {output_gif_path}") \ No newline at end of file diff --git a/image_optimizer.py b/image_optimizer.py new file mode 100644 index 000000000..22da4642f --- /dev/null +++ b/image_optimizer.py @@ -0,0 +1,77 @@ +from PIL import Image +import os + +input_dir = 'public/images' +output_dir = 'public/images_optimized' + +def optimize_image(input_path, output_path, quality=60, resize_factor=None, convert_to_webp=False): + """ + 画像を軽量化する関数 + + Args: + input_path: 入力画像のパス + output_path: 出力画像の保存先パス + quality: JPEG品質(0-100) + resize_factor: リサイズ比率(例:0.5で半分のサイズ) + convert_to_webp: WebP形式に変換するかどうか + """ + # 画像を開く + img = Image.open(input_path) + + # RGBAの場合はRGBに変換 + if img.mode == 'RGBA': + img = img.convert('RGB') + + # リサイズ処理 + if resize_factor and resize_factor > 0: + new_width = int(img.width * resize_factor) + new_height = int(img.height * resize_factor) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # WebP形式に変換する場合 + if convert_to_webp: + output_path = os.path.splitext(output_path)[0] + '.webp' + + # 画像を保存(圧縮) + img.save(output_path, + optimize=True, + quality=quality) + + # 実際に使用された出力パスを返す + return output_path + +def batch_optimize_images(input_dir, output_dir, quality=60, resize_factor=None, convert_to_webp=False): + """ + フォルダ内の画像を再帰的に一括で軽量化する関数 + """ + print(f"処理中のディレクトリ: {input_dir}") + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + for item in os.listdir(input_dir): + input_path = os.path.join(input_dir, item) + output_path = os.path.join(output_dir, item) + + print(f"検出されたアイテム: {item}") + + try: + if os.path.isdir(input_path): + print(f"フォルダを処理: {input_path}") + batch_optimize_images(input_path, output_path, quality, resize_factor, convert_to_webp) + elif item.lower().endswith(('.png', '.jpg', '.jpeg')): + print(f"画像を処理: {input_path}") + # 実際の出力パスを取得 + actual_output_path = optimize_image(input_path, output_path, quality, resize_factor, convert_to_webp) + + # 圧縮前後のファイルサイズを表示 + original_size = os.path.getsize(input_path) / 1024 + compressed_size = os.path.getsize(actual_output_path) / 1024 + print(f'{item}:') + print(f' 圧縮前: {original_size:.1f}KB') + print(f' 圧縮後: {compressed_size:.1f}KB') + if resize_factor: + print(f' リサイズ: 元のサイズの{int(resize_factor * 100)}%') + except Exception as e: + print(f"エラー発生 - ファイル: {item}") + print(f"エラー内容: {str(e)}") \ No newline at end of file diff --git a/jpeg-to-avif-ffmpeg.js b/jpeg-to-avif-ffmpeg.js new file mode 100644 index 000000000..bbe9c4e0c --- /dev/null +++ b/jpeg-to-avif-ffmpeg.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +/** + * JPEG画像をAVIF形式に変換するスクリプト (ffmpeg使用版) + * + * このスクリプトは、指定されたディレクトリ内のJPEG画像ファイルをAVIF形式に変換します。 + * 変換にはシステムにインストールされているffmpegを使用します。 + * AVIF形式は次世代の画像圧縮形式で、優れた圧縮率と画質を持ちます。 + * + * 使用方法: + * node jpeg-to-avif-ffmpeg.js + * + * 必要な環境: + * - ffmpeg がシステムにインストールされていること + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// 設定パラメータ +const INPUT_DIR = 'public/images'; // 入力ディレクトリ(JPEGファイルが格納されているディレクトリ) +const OUTPUT_DIR = 'public/images'; // 出力ディレクトリ(同じディレクトリに出力) +const QUALITY = 60; // AVIF品質(0-63)、値が低いほど高品質、高いほど圧縮率が上がる + +/** + * JPEG画像をAVIF形式に変換する関数 + * @param {string} jpegPath - 変換するJPEG画像のパス + * @param {string} avifPath - 出力するAVIF画像のパス + * @returns {Object} - 変換結果 + */ +function convertJpegToAvif(jpegPath, avifPath) { + try { + // ffmpegコマンドの構築 + // -y: 既存ファイルを上書き + // -i: 入力ファイル + // -c:v libaom-av1: AV1コーデックを使用 + // -crf: 品質(0-63、値が低いほど高品質) + // -b:v 0: ビットレートを自動調整 + const cmd = `ffmpeg -y -i "${jpegPath}" -c:v libaom-av1 -crf ${QUALITY} -b:v 0 "${avifPath}"`; + + // コマンド実行 + execSync(cmd, { stdio: 'pipe' }); + + // ファイルサイズ情報を取得 + const jpegStats = fs.statSync(jpegPath); + const avifStats = fs.statSync(avifPath); + + // KB単位のサイズ + const jpegSize = jpegStats.size / 1024; + const avifSize = avifStats.size / 1024; + + // 圧縮率を計算 + const compressionRatio = (1 - avifSize / jpegSize) * 100; + + console.log(`変換完了: ${path.basename(jpegPath)} → ${path.basename(avifPath)}`); + console.log(` 元のサイズ: ${jpegSize.toFixed(2)} KB`); + console.log(` AVIFサイズ: ${avifSize.toFixed(2)} KB`); + console.log(` 圧縮率: ${compressionRatio.toFixed(2)}%`); + + return { success: true }; + } catch (error) { + console.error(`エラー: ${jpegPath} の変換中にエラーが発生しました - ${error.message}`); + return { success: false, error }; + } +} + +/** + * ディレクトリ内のすべてのJPEG画像をAVIF形式に変換する関数 + */ +function batchConvertDirectory() { + // 出力ディレクトリが存在しない場合は作成 + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + // ディレクトリ内のファイル一覧を取得 + const files = fs.readdirSync(INPUT_DIR); + + // JPEG/JPGファイルのみをフィルタリング + const jpegFiles = files.filter(file => { + const ext = path.extname(file).toLowerCase(); + return ext === '.jpeg' || ext === '.jpg'; + }); + + if (jpegFiles.length === 0) { + console.log(`変換対象のJPEG画像が ${INPUT_DIR} に見つかりませんでした。`); + return; + } + + console.log(`合計 ${jpegFiles.length} 個のJPEG画像を変換します...`); + + // 変換開始時間を記録 + const startTime = Date.now(); + + // 変換カウンター + let successCount = 0; + let errorCount = 0; + + // 各ファイルを順番に変換 + for (const jpegFile of jpegFiles) { + const jpegPath = path.join(INPUT_DIR, jpegFile); + const avifFile = path.basename(jpegFile, path.extname(jpegFile)) + '.avif'; + const avifPath = path.join(OUTPUT_DIR, avifFile); + + // 変換を実行 + const result = convertJpegToAvif(jpegPath, avifPath); + + if (result.success) { + successCount++; + } else { + errorCount++; + } + } + + // 処理時間を計算 + const elapsedTime = (Date.now() - startTime) / 1000; + + // 結果を表示 + console.log("\n変換処理が完了しました。"); + console.log(` 成功: ${successCount} 個`); + console.log(` 失敗: ${errorCount} 個`); + console.log(` 処理時間: ${elapsedTime.toFixed(2)} 秒`); +} + +// メイン処理を実行 +console.log("JPEG画像をAVIF形式に変換するプロセスを開始します..."); +batchConvertDirectory(); +console.log("処理が完了しました。"); \ No newline at end of file diff --git a/jpeg-to-avif.js b/jpeg-to-avif.js new file mode 100644 index 000000000..eb61485af --- /dev/null +++ b/jpeg-to-avif.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +/** + * JPEG画像をAVIF形式に変換するスクリプト + * + * このスクリプトは、指定されたディレクトリ内のJPEG画像ファイルをAVIF形式に変換します。 + * AVIF形式は次世代の画像圧縮形式で、優れた圧縮率と画質を持ちます。 + * + * 使用方法: + * node jpeg-to-avif.js + * + * 必要なライブラリ: + * - sharp: `npm install sharp` + */ + +const fs = require('fs'); +const path = require('path'); +const sharp = require('sharp'); + +// 設定パラメータ +const INPUT_DIR = 'public/images'; // 入力ディレクトリ(JPEGファイルが格納されているディレクトリ) +const OUTPUT_DIR = 'public/images'; // 出力ディレクトリ(同じディレクトリに出力) +const QUALITY = 50; // AVIF品質(1-100)、値が低いほどファイルサイズが小さくなるが、画質は低下 +const EFFORT = 7; // 圧縮の努力レベル(0-9)、値が高いほど処理に時間がかかるが圧縮率が向上 + +/** + * JPEG画像をAVIF形式に変換する関数 + * @param {string} jpegPath - 変換するJPEG画像のパス + * @param {string} avifPath - 出力するAVIF画像のパス + * @returns {Promise} - 変換処理のPromise + */ +async function convertJpegToAvif(jpegPath, avifPath) { + try { + // AVIF形式に変換して保存 + await sharp(jpegPath) + .avif({ + quality: QUALITY, + effort: EFFORT + }) + .toFile(avifPath); + + // ファイルサイズ情報を取得 + const jpegStats = fs.statSync(jpegPath); + const avifStats = fs.statSync(avifPath); + + // KB単位のサイズ + const jpegSize = jpegStats.size / 1024; + const avifSize = avifStats.size / 1024; + + // 圧縮率を計算 + const compressionRatio = (1 - avifSize / jpegSize) * 100; + + console.log(`変換完了: ${path.basename(jpegPath)} → ${path.basename(avifPath)}`); + console.log(` 元のサイズ: ${jpegSize.toFixed(2)} KB`); + console.log(` AVIFサイズ: ${avifSize.toFixed(2)} KB`); + console.log(` 圧縮率: ${compressionRatio.toFixed(2)}%`); + + return { success: true }; + } catch (error) { + console.error(`エラー: ${jpegPath} の変換中にエラーが発生しました - ${error.message}`); + return { success: false, error }; + } +} + +/** + * ディレクトリ内のすべてのJPEG画像をAVIF形式に変換する関数 + */ +async function batchConvertDirectory() { + // 出力ディレクトリが存在しない場合は作成 + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + // ディレクトリ内のファイル一覧を取得 + const files = fs.readdirSync(INPUT_DIR); + + // JPEG/JPGファイルのみをフィルタリング + const jpegFiles = files.filter(file => { + const ext = path.extname(file).toLowerCase(); + return ext === '.jpeg' || ext === '.jpg'; + }); + + if (jpegFiles.length === 0) { + console.log(`変換対象のJPEG画像が ${INPUT_DIR} に見つかりませんでした。`); + return; + } + + console.log(`合計 ${jpegFiles.length} 個のJPEG画像を変換します...`); + + // 変換開始時間を記録 + const startTime = Date.now(); + + // 変換カウンター + let successCount = 0; + let errorCount = 0; + + // 各ファイルを順番に変換 + for (const jpegFile of jpegFiles) { + const jpegPath = path.join(INPUT_DIR, jpegFile); + const avifFile = path.basename(jpegFile, path.extname(jpegFile)) + '.avif'; + const avifPath = path.join(OUTPUT_DIR, avifFile); + + // 変換を実行 + const result = await convertJpegToAvif(jpegPath, avifPath); + + if (result.success) { + successCount++; + } else { + errorCount++; + } + } + + // 処理時間を計算 + const elapsedTime = (Date.now() - startTime) / 1000; + + // 結果を表示 + console.log("\n変換処理が完了しました。"); + console.log(` 成功: ${successCount} 個`); + console.log(` 失敗: ${errorCount} 個`); + console.log(` 処理時間: ${elapsedTime.toFixed(2)} 秒`); +} + +// メイン処理を実行 +console.log("JPEG画像をAVIF形式に変換するプロセスを開始します..."); +batchConvertDirectory() + .then(() => console.log("処理が完了しました。")) + .catch(error => console.error("エラーが発生しました:", error)); \ No newline at end of file diff --git a/jpeg_to_avif.py b/jpeg_to_avif.py new file mode 100644 index 000000000..bd72a467f --- /dev/null +++ b/jpeg_to_avif.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +JPEG画像をAVIF形式に変換するスクリプト + +このスクリプトは、指定されたディレクトリ内のJPEG画像ファイルをAVIF形式に変換します。 +AVIF形式は次世代の画像圧縮形式で、優れた圧縮率と画質を持ちます。 + +使用方法: +python jpeg_to_avif.py + +変換前の画像は保持されます。 +""" + +from PIL import Image +import os +import glob +import time + +# 設定パラメータ +INPUT_DIR = 'public/images' # 入力ディレクトリ(JPEGファイルが格納されているディレクトリ) +OUTPUT_DIR = 'public/images' # 出力ディレクトリ(同じディレクトリに出力) +QUALITY = 50 # AVIF品質(0-100)、値が低いほどファイルサイズが小さくなるが、画質は低下 +SPEED = 4 # エンコード速度(0-10)、値が低いほど高品質だが処理に時間がかかる + +def convert_jpeg_to_avif(jpeg_path, avif_path, quality=QUALITY, speed=SPEED): + """ + JPEG画像をAVIF形式に変換する関数 + + Args: + jpeg_path (str): 変換するJPEG画像のパス + avif_path (str): 出力するAVIF画像のパス + quality (int): AVIF品質(0-100) + speed (int): エンコード速度(0-10) + + Returns: + bool: 変換が成功したかどうか + """ + try: + # 画像を開く + img = Image.open(jpeg_path) + + # RGBAの場合はRGBに変換 + if img.mode == 'RGBA': + img = img.convert('RGB') + + # AVIF形式で保存 + img.save( + avif_path, + format='AVIF', + quality=quality, + speed=speed + ) + + # 元画像とAVIF画像のファイルサイズを取得(KB単位) + original_size = os.path.getsize(jpeg_path) / 1024 + avif_size = os.path.getsize(avif_path) / 1024 + + # 圧縮率を計算 + compression_ratio = (1 - avif_size / original_size) * 100 + + print(f"変換完了: {os.path.basename(jpeg_path)} → {os.path.basename(avif_path)}") + print(f" 元のサイズ: {original_size:.2f} KB") + print(f" AVIFサイズ: {avif_size:.2f} KB") + print(f" 圧縮率: {compression_ratio:.2f}%") + + return True + except Exception as e: + print(f"エラー: {jpeg_path} の変換中にエラーが発生しました - {str(e)}") + return False + +def batch_convert_directory(): + """ + ディレクトリ内のすべてのJPEG画像をAVIF形式に変換する関数 + """ + # 出力ディレクトリが存在しない場合は作成 + if not os.path.exists(OUTPUT_DIR): + os.makedirs(OUTPUT_DIR) + + # 対象となるJPEGファイルを検索 + jpeg_files = glob.glob(os.path.join(INPUT_DIR, "*.jpeg")) + jpeg_files += glob.glob(os.path.join(INPUT_DIR, "*.jpg")) + + if not jpeg_files: + print(f"変換対象のJPEG画像が {INPUT_DIR} に見つかりませんでした。") + return + + print(f"合計 {len(jpeg_files)} 個のJPEG画像を変換します...") + + # 変換の開始時間を記録 + start_time = time.time() + + # 変換カウンター + success_count = 0 + error_count = 0 + + # 各JPEGファイルを変換 + for jpeg_path in jpeg_files: + # 出力AVIFファイルパスを作成 + filename = os.path.basename(jpeg_path) + avif_filename = os.path.splitext(filename)[0] + ".avif" + avif_path = os.path.join(OUTPUT_DIR, avif_filename) + + # 変換実行 + if convert_jpeg_to_avif(jpeg_path, avif_path, QUALITY, SPEED): + success_count += 1 + else: + error_count += 1 + + # 処理時間を計算 + elapsed_time = time.time() - start_time + + # 結果を表示 + print("\n変換処理が完了しました。") + print(f" 成功: {success_count} 個") + print(f" 失敗: {error_count} 個") + print(f" 処理時間: {elapsed_time:.2f} 秒") + +if __name__ == "__main__": + print("JPEG画像をAVIF形式に変換するプロセスを開始します...") + batch_convert_directory() + print("処理が完了しました。") \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576486ce7..4783a3fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,9 +69,6 @@ importers: immer: specifier: 10.1.1 version: 10.1.1 - lodash: - specifier: 4.17.21 - version: 4.17.21 luxon: specifier: 3.5.0 version: 3.5.0 @@ -105,9 +102,6 @@ importers: react-use: specifier: 17.6.0 version: 17.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - setimmediate: - specifier: 1.0.5 - version: 1.0.5 tiny-invariant: specifier: 1.3.3 version: 1.3.3 @@ -123,9 +117,6 @@ importers: valibot: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(typescript@5.7.2) - view-transitions-polyfill: - specifier: 1.0.3 - version: 1.0.3 zod: specifier: 3.24.1 version: 3.24.1 @@ -160,9 +151,6 @@ importers: '@iconify/types': specifier: 2.0.0 version: 2.0.0 - '@types/lodash': - specifier: 4.17.16 - version: 4.17.16 '@types/luxon': specifier: 3.4.2 version: 3.4.2 @@ -199,9 +187,6 @@ importers: babel-loader: specifier: 9.2.1 version: 9.2.1(@babel/core@7.26.0)(webpack@5.96.1) - core-js: - specifier: 3.41.0 - version: 3.41.0 hls.js: specifier: 1.5.17 version: 1.5.17 @@ -1502,6 +1487,7 @@ packages: '@faker-js/faker@9.2.0': resolution: {integrity: sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} + deprecated: Please update to a newer version '@fastify/accept-negotiator@2.0.0': resolution: {integrity: sha512-/Sce/kBzuTxIq5tJh85nVNOq9wKD8s+viIgX0fFMDBdw95gnpf53qmF1oBgJym3cPFliWUuSloVg/1w/rH0FcQ==} @@ -2000,9 +1986,6 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/lodash@4.17.16': - resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==} - '@types/luxon@3.4.2': resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} @@ -2609,9 +2592,6 @@ packages: core-js-compat@3.39.0: resolution: {integrity: sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==} - core-js@3.41.0: - resolution: {integrity: sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3860,9 +3840,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -5139,9 +5116,6 @@ packages: videojs-vtt.js@0.15.5: resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==} - view-transitions-polyfill@1.0.3: - resolution: {integrity: sha512-o7tiNKAsuqvL0u4Epo/jrSOx7qy1/KH3ajzHsY0GU4zWIOiiC6Pyczyf6QgJneyFKlu8QdcMeCQocUl94R+fvA==} - watchpack@2.4.2: resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} engines: {node: '>=10.13.0'} @@ -6910,8 +6884,6 @@ snapshots: '@types/json5@0.0.29': {} - '@types/lodash@4.17.16': {} - '@types/luxon@3.4.2': {} '@types/m3u8-parser@7.2.0': {} @@ -7634,8 +7606,6 @@ snapshots: dependencies: browserslist: 4.24.2 - core-js@3.41.0: {} - core-util-is@1.0.3: {} cross-spawn@7.0.6: @@ -8988,8 +8958,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.21: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -10281,8 +10249,6 @@ snapshots: dependencies: global: 4.4.0 - view-transitions-polyfill@1.0.3: {} - watchpack@2.4.2: dependencies: glob-to-regexp: 0.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07b8a536d..e50281134 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,8 @@ packages: - ./workspaces/client - ./workspaces/server - ./workspaces/test +onlyBuiltDependencies: + - bcrypt +ignoredBuiltDependencies: + - core-js + - esbuild \ No newline at end of file diff --git a/public/animations/001.gif b/public/animations/001.gif index a047ac0a9..73f8cc136 100644 Binary files a/public/animations/001.gif and b/public/animations/001.gif differ diff --git a/public/images/001.avif b/public/images/001.avif new file mode 100644 index 000000000..8926840e7 Binary files /dev/null and b/public/images/001.avif differ diff --git a/public/images/001.jpeg b/public/images/001.jpeg deleted file mode 100644 index dc813003b..000000000 Binary files a/public/images/001.jpeg and /dev/null differ diff --git a/public/images/002.avif b/public/images/002.avif new file mode 100644 index 000000000..977734e56 Binary files /dev/null and b/public/images/002.avif differ diff --git a/public/images/002.jpeg b/public/images/002.jpeg deleted file mode 100644 index 1ece17f3e..000000000 Binary files a/public/images/002.jpeg and /dev/null differ diff --git a/public/images/003.avif b/public/images/003.avif new file mode 100644 index 000000000..dff780178 Binary files /dev/null and b/public/images/003.avif differ diff --git a/public/images/003.jpeg b/public/images/003.jpeg deleted file mode 100644 index ce28dbef0..000000000 Binary files a/public/images/003.jpeg and /dev/null differ diff --git a/public/images/004.avif b/public/images/004.avif new file mode 100644 index 000000000..719df9b12 Binary files /dev/null and b/public/images/004.avif differ diff --git a/public/images/004.jpeg b/public/images/004.jpeg deleted file mode 100644 index 1ac258743..000000000 Binary files a/public/images/004.jpeg and /dev/null differ diff --git a/public/images/005.avif b/public/images/005.avif new file mode 100644 index 000000000..f93996078 Binary files /dev/null and b/public/images/005.avif differ diff --git a/public/images/005.jpeg b/public/images/005.jpeg deleted file mode 100644 index 033c8cb54..000000000 Binary files a/public/images/005.jpeg and /dev/null differ diff --git a/public/images/006.avif b/public/images/006.avif new file mode 100644 index 000000000..7b1a52ae6 Binary files /dev/null and b/public/images/006.avif differ diff --git a/public/images/006.jpeg b/public/images/006.jpeg deleted file mode 100644 index 254f8611b..000000000 Binary files a/public/images/006.jpeg and /dev/null differ diff --git a/public/images/007.avif b/public/images/007.avif new file mode 100644 index 000000000..5e0ac7139 Binary files /dev/null and b/public/images/007.avif differ diff --git a/public/images/007.jpeg b/public/images/007.jpeg deleted file mode 100644 index f47135155..000000000 Binary files a/public/images/007.jpeg and /dev/null differ diff --git a/public/images/008.avif b/public/images/008.avif new file mode 100644 index 000000000..0f0b347f5 Binary files /dev/null and b/public/images/008.avif differ diff --git a/public/images/008.jpeg b/public/images/008.jpeg deleted file mode 100644 index 1b09e2d4e..000000000 Binary files a/public/images/008.jpeg and /dev/null differ diff --git a/public/images/009.avif b/public/images/009.avif new file mode 100644 index 000000000..8adc9bb04 Binary files /dev/null and b/public/images/009.avif differ diff --git a/public/images/009.jpeg b/public/images/009.jpeg deleted file mode 100644 index dddf48709..000000000 Binary files a/public/images/009.jpeg and /dev/null differ diff --git a/public/images/010.avif b/public/images/010.avif new file mode 100644 index 000000000..f43ec0ee3 Binary files /dev/null and b/public/images/010.avif differ diff --git a/public/images/010.jpeg b/public/images/010.jpeg deleted file mode 100644 index cbb93cccd..000000000 Binary files a/public/images/010.jpeg and /dev/null differ diff --git a/public/images/011.avif b/public/images/011.avif new file mode 100644 index 000000000..4c80a48da Binary files /dev/null and b/public/images/011.avif differ diff --git a/public/images/011.jpeg b/public/images/011.jpeg deleted file mode 100644 index a1daeebba..000000000 Binary files a/public/images/011.jpeg and /dev/null differ diff --git a/public/images/012.avif b/public/images/012.avif new file mode 100644 index 000000000..22e8e69d2 Binary files /dev/null and b/public/images/012.avif differ diff --git a/public/images/012.jpeg b/public/images/012.jpeg deleted file mode 100644 index c5426d6f9..000000000 Binary files a/public/images/012.jpeg and /dev/null differ diff --git a/public/images/013.avif b/public/images/013.avif new file mode 100644 index 000000000..aac88b916 Binary files /dev/null and b/public/images/013.avif differ diff --git a/public/images/013.jpeg b/public/images/013.jpeg deleted file mode 100644 index 8ad516a13..000000000 Binary files a/public/images/013.jpeg and /dev/null differ diff --git a/public/images/014.avif b/public/images/014.avif new file mode 100644 index 000000000..b88d60f86 Binary files /dev/null and b/public/images/014.avif differ diff --git a/public/images/014.jpeg b/public/images/014.jpeg deleted file mode 100644 index be184c423..000000000 Binary files a/public/images/014.jpeg and /dev/null differ diff --git a/public/images/015.avif b/public/images/015.avif new file mode 100644 index 000000000..37d664302 Binary files /dev/null and b/public/images/015.avif differ diff --git a/public/images/015.jpeg b/public/images/015.jpeg deleted file mode 100644 index 7485bee90..000000000 Binary files a/public/images/015.jpeg and /dev/null differ diff --git a/public/images/016.avif b/public/images/016.avif new file mode 100644 index 000000000..8a7c451f0 Binary files /dev/null and b/public/images/016.avif differ diff --git a/public/images/016.jpeg b/public/images/016.jpeg deleted file mode 100644 index 4cfbb87bd..000000000 Binary files a/public/images/016.jpeg and /dev/null differ diff --git a/public/images/017.avif b/public/images/017.avif new file mode 100644 index 000000000..48f4e895c Binary files /dev/null and b/public/images/017.avif differ diff --git a/public/images/017.jpeg b/public/images/017.jpeg deleted file mode 100644 index 39a2f2408..000000000 Binary files a/public/images/017.jpeg and /dev/null differ diff --git a/public/images/018.avif b/public/images/018.avif new file mode 100644 index 000000000..d42e8c292 Binary files /dev/null and b/public/images/018.avif differ diff --git a/public/images/018.jpeg b/public/images/018.jpeg deleted file mode 100644 index f6ae9d20f..000000000 Binary files a/public/images/018.jpeg and /dev/null differ diff --git a/public/images/019.avif b/public/images/019.avif new file mode 100644 index 000000000..e8e6b5138 Binary files /dev/null and b/public/images/019.avif differ diff --git a/public/images/019.jpeg b/public/images/019.jpeg deleted file mode 100644 index 2f36db641..000000000 Binary files a/public/images/019.jpeg and /dev/null differ diff --git a/public/images/020.avif b/public/images/020.avif new file mode 100644 index 000000000..45eac0da3 Binary files /dev/null and b/public/images/020.avif differ diff --git a/public/images/020.jpeg b/public/images/020.jpeg deleted file mode 100644 index b4a566b73..000000000 Binary files a/public/images/020.jpeg and /dev/null differ diff --git a/public/images/021.avif b/public/images/021.avif new file mode 100644 index 000000000..df1c666ed Binary files /dev/null and b/public/images/021.avif differ diff --git a/public/images/021.jpeg b/public/images/021.jpeg deleted file mode 100644 index 6d663e7f9..000000000 Binary files a/public/images/021.jpeg and /dev/null differ diff --git a/public/images/022.avif b/public/images/022.avif new file mode 100644 index 000000000..23c20b160 Binary files /dev/null and b/public/images/022.avif differ diff --git a/public/images/022.jpeg b/public/images/022.jpeg deleted file mode 100644 index aa5cccf8c..000000000 Binary files a/public/images/022.jpeg and /dev/null differ diff --git a/public/images/023.avif b/public/images/023.avif new file mode 100644 index 000000000..ce4ffb09b Binary files /dev/null and b/public/images/023.avif differ diff --git a/public/images/023.jpeg b/public/images/023.jpeg deleted file mode 100644 index 1e1e4e7d3..000000000 Binary files a/public/images/023.jpeg and /dev/null differ diff --git a/public/images/024.avif b/public/images/024.avif new file mode 100644 index 000000000..a480946be Binary files /dev/null and b/public/images/024.avif differ diff --git a/public/images/024.jpeg b/public/images/024.jpeg deleted file mode 100644 index c5e5f86c8..000000000 Binary files a/public/images/024.jpeg and /dev/null differ diff --git a/public/images/025.avif b/public/images/025.avif new file mode 100644 index 000000000..ced6848b3 Binary files /dev/null and b/public/images/025.avif differ diff --git a/public/images/025.jpeg b/public/images/025.jpeg deleted file mode 100644 index e1c9bc113..000000000 Binary files a/public/images/025.jpeg and /dev/null differ diff --git a/public/images/026.avif b/public/images/026.avif new file mode 100644 index 000000000..da80d579c Binary files /dev/null and b/public/images/026.avif differ diff --git a/public/images/026.jpeg b/public/images/026.jpeg deleted file mode 100644 index 9169af8dd..000000000 Binary files a/public/images/026.jpeg and /dev/null differ diff --git a/public/images/027.avif b/public/images/027.avif new file mode 100644 index 000000000..d4b6b2306 Binary files /dev/null and b/public/images/027.avif differ diff --git a/public/images/027.jpeg b/public/images/027.jpeg deleted file mode 100644 index 754c29f51..000000000 Binary files a/public/images/027.jpeg and /dev/null differ diff --git a/public/images/028.avif b/public/images/028.avif new file mode 100644 index 000000000..632982182 Binary files /dev/null and b/public/images/028.avif differ diff --git a/public/images/028.jpeg b/public/images/028.jpeg deleted file mode 100644 index d27b403be..000000000 Binary files a/public/images/028.jpeg and /dev/null differ diff --git a/public/images/029.avif b/public/images/029.avif new file mode 100644 index 000000000..d6cbc7e77 Binary files /dev/null and b/public/images/029.avif differ diff --git a/public/images/029.jpeg b/public/images/029.jpeg deleted file mode 100644 index e74fa0ed4..000000000 Binary files a/public/images/029.jpeg and /dev/null differ diff --git a/public/images/030.avif b/public/images/030.avif new file mode 100644 index 000000000..55edf6ce6 Binary files /dev/null and b/public/images/030.avif differ diff --git a/public/images/030.jpeg b/public/images/030.jpeg deleted file mode 100644 index 9d7fe74ff..000000000 Binary files a/public/images/030.jpeg and /dev/null differ diff --git a/public/images/031.avif b/public/images/031.avif new file mode 100644 index 000000000..b922d8ad0 Binary files /dev/null and b/public/images/031.avif differ diff --git a/public/images/031.jpeg b/public/images/031.jpeg deleted file mode 100644 index 926a3ee74..000000000 Binary files a/public/images/031.jpeg and /dev/null differ diff --git a/public/images/032.avif b/public/images/032.avif new file mode 100644 index 000000000..19770e752 Binary files /dev/null and b/public/images/032.avif differ diff --git a/public/images/032.jpeg b/public/images/032.jpeg deleted file mode 100644 index 57d8a4424..000000000 Binary files a/public/images/032.jpeg and /dev/null differ diff --git a/public/images/033.avif b/public/images/033.avif new file mode 100644 index 000000000..1e2f4749e Binary files /dev/null and b/public/images/033.avif differ diff --git a/public/images/033.jpeg b/public/images/033.jpeg deleted file mode 100644 index cb8d05eb9..000000000 Binary files a/public/images/033.jpeg and /dev/null differ diff --git a/public/images/034.avif b/public/images/034.avif new file mode 100644 index 000000000..28701bd8f Binary files /dev/null and b/public/images/034.avif differ diff --git a/public/images/034.jpeg b/public/images/034.jpeg deleted file mode 100644 index d96e76079..000000000 Binary files a/public/images/034.jpeg and /dev/null differ diff --git a/public/images/035.avif b/public/images/035.avif new file mode 100644 index 000000000..5fe21ad47 Binary files /dev/null and b/public/images/035.avif differ diff --git a/public/images/035.jpeg b/public/images/035.jpeg deleted file mode 100644 index 0d3a506f3..000000000 Binary files a/public/images/035.jpeg and /dev/null differ diff --git a/public/images/036.avif b/public/images/036.avif new file mode 100644 index 000000000..578584b76 Binary files /dev/null and b/public/images/036.avif differ diff --git a/public/images/036.jpeg b/public/images/036.jpeg deleted file mode 100644 index 1f349e9ab..000000000 Binary files a/public/images/036.jpeg and /dev/null differ diff --git a/public/images/037.avif b/public/images/037.avif new file mode 100644 index 000000000..302923bec Binary files /dev/null and b/public/images/037.avif differ diff --git a/public/images/037.jpeg b/public/images/037.jpeg deleted file mode 100644 index b18bed87b..000000000 Binary files a/public/images/037.jpeg and /dev/null differ diff --git a/public/svg/house-fill.svg b/public/svg/house-fill.svg new file mode 100644 index 000000000..aea1bfa2c --- /dev/null +++ b/public/svg/house-fill.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/svg/loading.svg b/public/svg/loading.svg new file mode 100644 index 000000000..78c7f9478 --- /dev/null +++ b/public/svg/loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/user.svg b/public/svg/user.svg new file mode 100644 index 000000000..0dc276a65 --- /dev/null +++ b/public/svg/user.svg @@ -0,0 +1 @@ + diff --git a/run_optimizer.py b/run_optimizer.py new file mode 100644 index 000000000..e412f11ab --- /dev/null +++ b/run_optimizer.py @@ -0,0 +1,15 @@ +from image_optimizer import batch_optimize_images +import os + +# 入力・出力パスの設定 +input_dir = 'public/images' +output_dir = 'public/images_optimized' + +# 画像の一括最適化 +batch_optimize_images( + input_dir=input_dir, + output_dir=output_dir, + quality=60, # 低品質設定 + resize_factor=0.1, # 10%にリサイズ + convert_to_webp=False # JPEGのまま +) \ No newline at end of file diff --git a/ts_optimizer.py b/ts_optimizer.py new file mode 100644 index 000000000..c17d3cba0 --- /dev/null +++ b/ts_optimizer.py @@ -0,0 +1,148 @@ +import os +import subprocess +import shutil +from pathlib import Path + +# 入力・出力ディレクトリの設定 +input_base_dir = 'workspaces/server/streams' +output_base_dir = 'workspaces/server/streams_optimized' + +def compress_ts_file(input_path, output_path, crf=28): + """ + TSファイルをFFmpegを使って圧縮する関数 + + Args: + input_path: 入力TSファイルのパス + output_path: 出力TSファイルの保存先パス + crf: 圧縮品質 (18-28が推奨、数値が大きいほど圧縮率が高く品質が下がる) + """ + try: + # 出力ディレクトリが存在しない場合は作成 + output_dir = os.path.dirname(output_path) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # 一時ファイルを作成(mp4形式に一旦変換) + temp_output = f"{output_path}.mp4" + + # FFmpegでTSファイルをより効率的な形式に圧縮 + cmd = [ + 'ffmpeg', + '-i', input_path, # 入力ファイル + '-c:v', 'libx264', # ビデオコーデック + '-crf', str(crf), # 圧縮品質 + '-preset', 'medium', # エンコード速度と圧縮率のバランス + '-c:a', 'aac', # オーディオコーデック + '-b:a', '128k', # オーディオビットレート + '-y', # 確認なしで上書き + temp_output # 一時出力ファイル + ] + + print(f"FFmpegを実行: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + print(f"エラー: {result.stderr}") + return None + + # mp4からtsに戻す + ts_cmd = [ + 'ffmpeg', + '-i', temp_output, # 入力ファイル(一時mp4) + '-c', 'copy', # コーデックをコピー + '-bsf:v', 'h264_mp4toannexb', # ビットストリームフィルタ + '-f', 'mpegts', # 出力フォーマット + '-y', # 確認なしで上書き + output_path # 最終出力ファイル + ] + + print(f"TSに戻すFFmpegを実行: {' '.join(ts_cmd)}") + ts_result = subprocess.run(ts_cmd, capture_output=True, text=True) + + # 一時ファイルを削除 + if os.path.exists(temp_output): + os.remove(temp_output) + + if ts_result.returncode != 0: + print(f"TSへの変換エラー: {ts_result.stderr}") + return None + + return output_path + + except Exception as e: + print(f"圧縮処理中にエラーが発生: {str(e)}") + return None + +def process_directory(input_dir, output_dir, crf=28): + """ + ディレクトリ内のTSファイルを再帰的に一括で圧縮する関数 + """ + print(f"処理中のディレクトリ: {input_dir}") + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + for item in os.listdir(input_dir): + input_path = os.path.join(input_dir, item) + output_path = os.path.join(output_dir, item) + + try: + if os.path.isdir(input_path): + print(f"フォルダを処理: {input_path}") + process_directory(input_path, output_path, crf) + elif item.lower().endswith('.ts'): + print(f"TSファイルを処理: {input_path}") + + # 元のファイルサイズを取得 + original_size = os.path.getsize(input_path) / 1024 / 1024 # MB単位 + + # ファイルを圧縮 + result_path = compress_ts_file(input_path, output_path, crf) + + if result_path: + # 圧縮後のファイルサイズを取得 + compressed_size = os.path.getsize(result_path) / 1024 / 1024 # MB単位 + + # 圧縮率と結果の表示 + reduction = (1 - compressed_size / original_size) * 100 + print(f'{item}:') + print(f' 圧縮前: {original_size:.2f}MB') + print(f' 圧縮後: {compressed_size:.2f}MB') + print(f' 削減率: {reduction:.1f}%') + else: + print(f"圧縮に失敗: {input_path}") + else: + # TSファイル以外はそのままコピー + shutil.copy2(input_path, output_path) + print(f"ファイルをコピー: {input_path} -> {output_path}") + except Exception as e: + print(f"エラー発生 - ファイル: {item}") + print(f"エラー内容: {str(e)}") + +def main(): + """ + メイン関数: 指定されたディレクトリ内のすべてのTSファイルを圧縮 + """ + # 基本ディレクトリのパスを確認 + base_dir = Path(input_base_dir) + output_dir = Path(output_base_dir) + + if not base_dir.exists(): + print(f"エラー: 入力ディレクトリ {base_dir} が存在しません") + return + + # 出力ディレクトリがない場合は作成 + if not output_dir.exists(): + output_dir.mkdir(parents=True, exist_ok=True) + + # 各サブディレクトリを処理 + for subdir in base_dir.iterdir(): + if subdir.is_dir(): + print(f"ディレクトリを処理: {subdir.name}") + output_subdir = output_dir / subdir.name + process_directory(str(subdir), str(output_subdir)) + + print("処理が完了しました") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ts_optimizer_simple.py b/ts_optimizer_simple.py new file mode 100644 index 000000000..de6d6ad2d --- /dev/null +++ b/ts_optimizer_simple.py @@ -0,0 +1,125 @@ +import os +import zlib +from pathlib import Path +import struct +import shutil +import gzip + +# 入力・出力ディレクトリの設定 +input_base_dir = 'workspaces/server/streams' +output_base_dir = 'workspaces/server/streams_optimized' + +def simple_compress_ts(input_path, output_path, quality=5): + """ + TSファイルをシンプルに圧縮する関数 + TS(Transport Stream)ファイルは188バイトのパケットの集まりなので、 + その構造を維持しながらデータを圧縮します。 + + Args: + input_path: 入力TSファイルのパス + output_path: 出力TSファイルの保存先パス + quality: 圧縮品質 (1-9、数値が大きいほど圧縮率は高いが処理は遅くなる) + """ + try: + # 出力ディレクトリが存在しない場合は作成 + output_dir = os.path.dirname(output_path) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + # TSファイルを読み込み + with open(input_path, 'rb') as f: + data = f.read() + + # 元のファイルサイズを記録 + original_size = len(data) + + # gzipで圧縮した一時ファイルを作成 + temp_gz_path = output_path + '.gz' + with gzip.open(temp_gz_path, 'wb', compresslevel=quality) as f: + f.write(data) + + # 圧縮したファイルを元の名前にリネーム + shutil.move(temp_gz_path, output_path) + + # 圧縮後のサイズを取得 + compressed_size = os.path.getsize(output_path) + + return output_path, original_size, compressed_size + + except Exception as e: + print(f"圧縮処理中にエラーが発生: {str(e)}") + return None, 0, 0 + +def process_directory(input_dir, output_dir, quality=5): + """ + ディレクトリ内のTSファイルを再帰的に一括で圧縮する関数 + """ + print(f"処理中のディレクトリ: {input_dir}") + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + for item in os.listdir(input_dir): + input_path = os.path.join(input_dir, item) + output_path = os.path.join(output_dir, item) + + try: + if os.path.isdir(input_path): + print(f"フォルダを処理: {input_path}") + process_directory(input_path, output_path, quality) + elif item.lower().endswith('.ts'): + print(f"TSファイルを処理: {input_path}") + + # ファイルを圧縮 + result_path, original_size, compressed_size = simple_compress_ts(input_path, output_path, quality) + + if result_path: + # 圧縮率と結果の表示 + original_mb = original_size / 1024 / 1024 # MB単位 + compressed_mb = compressed_size / 1024 / 1024 # MB単位 + reduction = (1 - compressed_size / original_size) * 100 if original_size > 0 else 0 + + print(f'{item}:') + print(f' 圧縮前: {original_mb:.2f}MB') + print(f' 圧縮後: {compressed_mb:.2f}MB') + print(f' 削減率: {reduction:.1f}%') + else: + print(f"圧縮に失敗: {input_path}") + else: + # TSファイル以外はそのままコピー + shutil.copy2(input_path, output_path) + print(f"ファイルをコピー: {input_path} -> {output_path}") + except Exception as e: + print(f"エラー発生 - ファイル: {item}") + print(f"エラー内容: {str(e)}") + +def main(): + """ + メイン関数: 指定されたディレクトリ内のすべてのTSファイルを圧縮 + """ + # 基本ディレクトリのパスを確認 + base_dir = Path(input_base_dir) + output_dir = Path(output_base_dir) + + if not base_dir.exists(): + print(f"エラー: 入力ディレクトリ {base_dir} が存在しません") + return + + # 出力ディレクトリがない場合は作成 + if not output_dir.exists(): + output_dir.mkdir(parents=True, exist_ok=True) + + # 圧縮品質の設定 (1-9) + compression_quality = 7 # 高い圧縮率(処理は遅くなる) + + # 各サブディレクトリを処理 + for subdir in base_dir.iterdir(): + if subdir.is_dir(): + print(f"ディレクトリを処理: {subdir.name}") + output_subdir = output_dir / subdir.name + process_directory(str(subdir), str(output_subdir), compression_quality) + + print("処理が完了しました") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/workspaces/client/package.json b/workspaces/client/package.json index f6c261693..294d366fa 100644 --- a/workspaces/client/package.json +++ b/workspaces/client/package.json @@ -22,7 +22,6 @@ "classnames": "2.5.1", "final-form": "4.20.10", "immer": "10.1.1", - "lodash": "4.17.21", "luxon": "3.5.0", "m3u8-parser": "7.2.0", "p-min-delay": "4.0.2", @@ -34,13 +33,11 @@ "react-router": "7.0.2", "react-router-dom": "7.0.2", "react-use": "17.6.0", - "setimmediate": "1.0.5", "tiny-invariant": "1.3.3", "type-fest": "4.29.1", "use-callback-ref": "1.3.3", "use-sync-external-store": "1.4.0", "valibot": "1.0.0-rc.3", - "view-transitions-polyfill": "1.0.3", "zod": "3.24.1", "zustand": "5.0.3", "zustand-di": "0.0.16" @@ -54,7 +51,6 @@ "@ffmpeg/util": "0.12.2", "@iconify/json": "2.2.317", "@iconify/types": "2.0.0", - "@types/lodash": "4.17.16", "@types/luxon": "3.4.2", "@types/m3u8-parser": "7.2.0", "@types/react": "19.0.1", @@ -67,7 +63,6 @@ "@wsh-2025/configs": "workspace:*", "arraybuffer-loader": "1.0.8", "babel-loader": "9.2.1", - "core-js": "3.41.0", "hls.js": "1.5.17", "shaka-player": "4.12.5", "typescript": "5.7.2", diff --git a/workspaces/client/src/app/Document.tsx b/workspaces/client/src/app/Document.tsx index 0ca7b8252..c89d085ed 100644 --- a/workspaces/client/src/app/Document.tsx +++ b/workspaces/client/src/app/Document.tsx @@ -1,30 +1,23 @@ import { Suspense } from 'react'; import { Outlet, ScrollRestoration } from 'react-router'; -import { createStore } from '@wsh-2025/client/src/app/createStore'; +// import { createStore } from '@wsh-2025/client/src/app/createStore'; import { Layout } from '@wsh-2025/client/src/features/layout/components/Layout'; -export const prefetch = async (store: ReturnType) => { - const user = await store.getState().features.auth.fetchUser(); - return { user }; -}; +// export const prefetch = async (store: ReturnType) => { +// const user = await store.getState().features.auth.fetchUser(); +// return { user }; +// }; export const Document = () => { return ( - - - - - - - - - - - - - - - +
+ Loading...
}> + + + + + + ); }; diff --git a/workspaces/client/src/app/createRoutes.tsx b/workspaces/client/src/app/createRoutes.tsx index a81e12561..19a703d6b 100644 --- a/workspaces/client/src/app/createRoutes.tsx +++ b/workspaces/client/src/app/createRoutes.tsx @@ -4,6 +4,9 @@ import { RouteObject } from 'react-router'; import { Document, prefetch } from '@wsh-2025/client/src/app/Document'; import { createStore } from '@wsh-2025/client/src/app/createStore'; +// 最小限の遅延を設定(1msは実質的に即時実行に近い) +const minLazyDelay = 10; + export function createRoutes(store: ReturnType): RouteObject[] { return [ { @@ -13,7 +16,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { HomePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/home/components/HomePage'), - 1000, + minLazyDelay, ); return { Component: HomePage, @@ -27,7 +30,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { EpisodePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/episode/components/EpisodePage'), - 1000, + minLazyDelay, ); return { Component: EpisodePage, @@ -42,7 +45,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, ProgramPage } = await lazy( import('@wsh-2025/client/src/pages/program/components/ProgramPage'), - 1000, + minLazyDelay, ); return { Component: ProgramPage, @@ -57,7 +60,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, SeriesPage } = await lazy( import('@wsh-2025/client/src/pages/series/components/SeriesPage'), - 1000, + minLazyDelay, ); return { Component: SeriesPage, @@ -72,7 +75,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, TimetablePage } = await lazy( import('@wsh-2025/client/src/pages/timetable/components/TimetablePage'), - 1000, + minLazyDelay, ); return { Component: TimetablePage, @@ -87,22 +90,22 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { NotFoundPage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/not_found/components/NotFoundPage'), - 1000, + minLazyDelay, ); return { Component: NotFoundPage, - async loader() { - return await prefetch(store); - }, + // async loader() { + // return await prefetch(store); + // }, }; }, path: '*', }, ], Component: Document, - async loader() { - return await prefetch(store); - }, + // async loader() { + // return await prefetch(store); + // }, path: '/', }, ]; diff --git a/workspaces/client/src/app/createStore.ts b/workspaces/client/src/app/createStore.ts index 92d2abaaf..71323d487 100644 --- a/workspaces/client/src/app/createStore.ts +++ b/workspaces/client/src/app/createStore.ts @@ -1,5 +1,4 @@ import { withLenses } from '@dhmk/zustand-lens'; -import _ from 'lodash'; import { createStore as createZustandStore } from 'zustand/vanilla'; import { createAuthStoreSlice } from '@wsh-2025/client/src/features/auth/stores/createAuthStoreSlice'; @@ -39,7 +38,26 @@ export const createStore = ({ hydrationData }: Props) => { })), ); - store.setState((s) => _.merge(s, hydrationData)); + if (hydrationData) { + store.setState((s) => { + return { + ...s, + ...hydrationData, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + features: { + ...s.features, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + ...(hydrationData as any)?.features, + }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + pages: { + ...s.pages, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + ...(hydrationData as any)?.pages, + }, + }; + }); + } return store; }; diff --git a/workspaces/client/src/features/auth/components/SignInDialog.tsx b/workspaces/client/src/features/auth/components/SignInDialog.tsx index 350d89edc..973adce1e 100644 --- a/workspaces/client/src/features/auth/components/SignInDialog.tsx +++ b/workspaces/client/src/features/auth/components/SignInDialog.tsx @@ -47,7 +47,7 @@ export const SignInDialog = ({ isOpen, onClose, onOpenSignUp }: Props) => {
- +

ログイン

diff --git a/workspaces/client/src/features/auth/components/SignOutDialog.tsx b/workspaces/client/src/features/auth/components/SignOutDialog.tsx index d7c1fd336..ddbc61a78 100644 --- a/workspaces/client/src/features/auth/components/SignOutDialog.tsx +++ b/workspaces/client/src/features/auth/components/SignOutDialog.tsx @@ -28,7 +28,7 @@ export const SignOutDialog = ({ isOpen, onClose }: Props) => {
- +

ログアウト

diff --git a/workspaces/client/src/features/auth/components/SignUpDialog.tsx b/workspaces/client/src/features/auth/components/SignUpDialog.tsx index 1cb939f8d..6ed1b4340 100644 --- a/workspaces/client/src/features/auth/components/SignUpDialog.tsx +++ b/workspaces/client/src/features/auth/components/SignUpDialog.tsx @@ -47,7 +47,7 @@ export const SignUpDialog = ({ isOpen, onClose, onOpenSignIn }: Props) => {
- +

会員登録

diff --git a/workspaces/client/src/features/layout/components/AspectRatio.tsx b/workspaces/client/src/features/layout/components/AspectRatio.tsx deleted file mode 100644 index 37b07a5a1..000000000 --- a/workspaces/client/src/features/layout/components/AspectRatio.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ReactNode, useEffect, useRef } from 'react'; -import { useUpdate } from 'react-use'; - -interface Props { - children: ReactNode; - ratioHeight: number; - ratioWidth: number; -} - -export const AspectRatio = ({ children, ratioHeight, ratioWidth }: Props) => { - const forceUpdate = useUpdate(); - const containerRef = useRef(null); - - useEffect(() => { - const interval = setInterval(function tick() { - forceUpdate(); - }, 1000); - return () => { - clearInterval(interval); - }; - }, []); - - const width = containerRef.current?.getBoundingClientRect().width ?? 0; - const height = (width * ratioHeight) / ratioWidth; - - return ( -
- {children} -
- ); -}; diff --git a/workspaces/client/src/features/layout/components/Hoverable.tsx b/workspaces/client/src/features/layout/components/Hoverable.tsx index b723d8aee..269782d2d 100644 --- a/workspaces/client/src/features/layout/components/Hoverable.tsx +++ b/workspaces/client/src/features/layout/components/Hoverable.tsx @@ -1,9 +1,7 @@ import classNames from 'classnames'; -import { Children, cloneElement, ReactElement, Ref, useRef } from 'react'; +import { Children, cloneElement, ReactElement, Ref, useCallback, useEffect, useRef, useState } from 'react'; import { useMergeRefs } from 'use-callback-ref'; -import { usePointer } from '@wsh-2025/client/src/features/layout/hooks/usePointer'; - interface Props { children: ReactElement<{ className?: string; ref?: Ref }>; classNames: { @@ -15,18 +13,32 @@ interface Props { export const Hoverable = (props: Props) => { const child = Children.only(props.children); const elementRef = useRef(null); + const [hovered, setHovered] = useState(false); const mergedRef = useMergeRefs([elementRef, child.props.ref].filter((v) => v != null)); - const pointer = usePointer(); - const elementRect = elementRef.current?.getBoundingClientRect(); + // マウスイベントを使用してホバー状態を管理 + const handleMouseEnter = useCallback(() => { + setHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setHovered(false); + }, []); + + // イベントリスナーを設定 + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + element.addEventListener('mouseenter', handleMouseEnter); + element.addEventListener('mouseleave', handleMouseLeave); - const hovered = - elementRect != null && - elementRect.left <= pointer.x && - pointer.x <= elementRect.right && - elementRect.top <= pointer.y && - pointer.y <= elementRect.bottom; + return () => { + element.removeEventListener('mouseenter', handleMouseEnter); + element.removeEventListener('mouseleave', handleMouseLeave); + }; + }, [handleMouseEnter, handleMouseLeave]); return cloneElement(child, { className: classNames( diff --git a/workspaces/client/src/features/layout/components/Layout.tsx b/workspaces/client/src/features/layout/components/Layout.tsx index 99eadef4b..34b2c86f4 100644 --- a/workspaces/client/src/features/layout/components/Layout.tsx +++ b/workspaces/client/src/features/layout/components/Layout.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { ReactNode, useEffect, useState } from 'react'; +import { lazy, ReactNode, Suspense, useEffect, useState } from 'react'; import { Flipper } from 'react-flip-toolkit'; import { Link, useLocation, useNavigation } from 'react-router'; @@ -11,15 +11,30 @@ import { useAuthActions } from '@wsh-2025/client/src/features/auth/hooks/useAuth import { useAuthDialogType } from '@wsh-2025/client/src/features/auth/hooks/useAuthDialogType'; import { useAuthUser } from '@wsh-2025/client/src/features/auth/hooks/useAuthUser'; import { Loading } from '@wsh-2025/client/src/features/layout/components/Loading'; -import { useSubscribePointer } from '@wsh-2025/client/src/features/layout/hooks/useSubscribePointer'; +import { debounce } from '@wsh-2025/client/src/utils/debounce'; + +// ダイアログコンポーネントを遅延ロード +// const SignInDialog = lazy(() => +// import('@wsh-2025/client/src/features/auth/components/SignInDialog').then((module) => ({ +// default: module.SignInDialog, +// })), +// ); +// const SignUpDialog = lazy(() => +// import('@wsh-2025/client/src/features/auth/components/SignUpDialog').then((module) => ({ +// default: module.SignUpDialog, +// })), +// ); +// const SignOutDialog = lazy(() => +// import('@wsh-2025/client/src/features/auth/components/SignOutDialog').then((module) => ({ +// default: module.SignOutDialog, +// })), +// ); interface Props { children: ReactNode; } export const Layout = ({ children }: Props) => { - useSubscribePointer(); - const navigation = useNavigation(); const isLoading = navigation.location != null && (navigation.location.state as { loading?: string } | null)?.['loading'] !== 'none'; @@ -35,14 +50,16 @@ export const Layout = ({ children }: Props) => { const [shouldHeaderBeTransparent, setShouldHeaderBeTransparent] = useState(false); useEffect(() => { - const handleScroll = () => { + // スクロールイベントにdebounceを適用 + const handleScroll = debounce(() => { setScrollTopOffset(window.scrollY); - }; + }, 16); // 約60FPSに制限 window.addEventListener('scroll', handleScroll); return () => { window.removeEventListener('scroll', handleScroll); + handleScroll.cancel(); // メモリリークを防止 }; }, []); @@ -64,7 +81,7 @@ export const Layout = ({ children }: Props) => { )} > - AREMA + AREMA @@ -75,9 +92,26 @@ export const Layout = ({ children }: Props) => { type="button" onClick={isSignedIn ? authActions.openSignOutDialog : authActions.openSignInDialog} > -
+
+ {isSignedIn ? ( + // ログアウトアイコン + //
+ + + + ) : ( + // ログインアイコン + + + + )} +
{isSignedIn ? 'ログアウト' : 'ログイン'} @@ -87,7 +121,25 @@ export const Layout = ({ children }: Props) => { className="block flex h-[56px] w-[188px] items-center justify-center pb-[8px] pl-[20px] pr-[8px] pt-[8px]" to="/" > -
+ {/* ホームアイコン */} + + + + + {/*
*/} ホーム @@ -95,7 +147,19 @@ export const Layout = ({ children }: Props) => { className="block flex h-[56px] w-[188px] items-center justify-center pb-[8px] pl-[20px] pr-[8px] pt-[8px]" to="/timetable" > -
+ {/* カレンダーアイコン */} +
+ + + +
+ {/*
*/} 番組表 @@ -114,17 +178,16 @@ export const Layout = ({ children }: Props) => { ) : null}
- - - + {/* 必要なときだけダイアログをレンダリング */} + + {authDialogType === AuthDialogType.SignIn && ( + + )} + {authDialogType === AuthDialogType.SignUp && ( + + )} + {authDialogType === AuthDialogType.SignOut && } + ); }; diff --git a/workspaces/client/src/features/layout/components/Loading.tsx b/workspaces/client/src/features/layout/components/Loading.tsx index 4a61fe30c..795c0bb44 100644 --- a/workspaces/client/src/features/layout/components/Loading.tsx +++ b/workspaces/client/src/features/layout/components/Loading.tsx @@ -1,7 +1,8 @@ export const Loading = () => { return (
-
+ {/*
*/} +
); }; diff --git a/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts b/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts index 5dc37a174..30f337619 100644 --- a/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts +++ b/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts @@ -1,30 +1,24 @@ import { useEffect } from 'react'; import { useStore } from '@wsh-2025/client/src/app/StoreContext'; +import { debounce } from '@wsh-2025/client/src/utils/debounce'; export function useSubscribePointer(): void { - const s = useStore((s) => s); + const updatePointer = useStore((s) => s.features.layout.updatePointer); useEffect(() => { const abortController = new AbortController(); - const current = { x: 0, y: 0 }; - const handlePointerMove = (ev: MouseEvent) => { - current.x = ev.clientX; - current.y = ev.clientY; - }; - window.addEventListener('pointermove', handlePointerMove, { signal: abortController.signal }); + // 位置が変わったときだけ更新するように最適化 + const handlePointerMove = debounce((...args: unknown[]) => { + const ev = args[0] as MouseEvent; + updatePointer({ x: ev.clientX, y: ev.clientY }); + }, 16); // 約60FPSに制限 - let immediate = setImmediate(function tick() { - s.features.layout.updatePointer({ ...current }); - immediate = setImmediate(tick); - }); - abortController.signal.addEventListener('abort', () => { - clearImmediate(immediate); - }); + window.addEventListener('pointermove', handlePointerMove, { signal: abortController.signal }); return () => { abortController.abort(); }; - }, []); + }, [updatePointer]); } diff --git a/workspaces/client/src/features/player/components/Player.tsx b/workspaces/client/src/features/player/components/Player.tsx index f1c27e8b9..d152988be 100644 --- a/workspaces/client/src/features/player/components/Player.tsx +++ b/workspaces/client/src/features/player/components/Player.tsx @@ -49,7 +49,8 @@ export const Player = ({ className, loop, playerRef, playerType, playlistUrl }:
-
+ + {/*
*/}
diff --git a/workspaces/client/src/features/player/logics/create_player.ts b/workspaces/client/src/features/player/logics/create_player.ts index 823364d27..cefb23de8 100644 --- a/workspaces/client/src/features/player/logics/create_player.ts +++ b/workspaces/client/src/features/player/logics/create_player.ts @@ -19,8 +19,14 @@ class ShakaPlayerWrapper implements PlayerWrapper { constructor(playerType: PlayerType.ShakaPlayer) { this.playerType = playerType; this._player.configure({ + // パフォーマンス最適化のための設定 + abr: { + defaultBandwidthEstimate: 500000, + enabled: true, // 初期帯域幅の見積もり(500kbps) + }, streaming: { bufferingGoal: 50, + // useNativeHlsOnSafari: true, // SafariではネイティブHLSを使用 }, }); } @@ -72,8 +78,17 @@ class HlsJSPlayerWrapper implements PlayerWrapper { volume: 0.25, }); private _player = new HlsJs({ - enableWorker: false, - maxBufferLength: 50, + // レベルロードの最大リトライ回数 + abrEwmaDefaultEstimate: 500000, // メインスレッドの負荷を軽減するための追加設定 + backBufferLength: 30, + enableWorker: true, + // バックバッファの長さを制限 + fragLoadingMaxRetry: 2, + // マニフェストロードの最大リトライ回数 + levelLoadingMaxRetry: 2, liveSyncDurationCount: 3, // フラグメントロードの最大リトライ回数 + manifestLoadingMaxRetry: 2, // Web Workerを有効化 + maxBufferLength: 10, maxMaxBufferLength: 30, // 初期帯域幅の見積もり(500kbps) + testBandwidth: false, // 初期帯域幅テストを無効化 }); readonly playerType: PlayerType.HlsJS; @@ -138,6 +153,18 @@ class VideoJSPlayerWrapper implements PlayerWrapper { vhsConfig.GOAL_BUFFER_LENGTH = 50; vhsConfig.MAX_GOAL_BUFFER_LENGTH = 50; this.playerType = playerType; + + // パフォーマンス最適化のための設定 + this._player.options({ + html5: { + vhs: { + enableLowInitialPlaylist: true, + limitRenditionByPlayerDimensions: true, + overrideNative: !videojs.browser.IS_SAFARI, + useDevicePixelRatio: true, + }, + }, + }); } get currentTime(): number { diff --git a/workspaces/client/src/features/recommended/components/CarouselSection.tsx b/workspaces/client/src/features/recommended/components/CarouselSection.tsx index e81aa410e..6a20c556b 100644 --- a/workspaces/client/src/features/recommended/components/CarouselSection.tsx +++ b/workspaces/client/src/features/recommended/components/CarouselSection.tsx @@ -1,6 +1,7 @@ import { ElementScrollRestoration } from '@epic-web/restore-scroll'; import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; +import { memo } from 'react'; import { ArrayValues } from 'type-fest'; import { useMergeRefs } from 'use-callback-ref'; @@ -13,6 +14,24 @@ interface Props { module: ArrayValues>; } +// メモ化されたアイテムコンポーネント +const CarouselItem = memo(({ + item, + width +}: { + item: ArrayValues>['items'][0]; + width: number; +}) => { + return ( +
+ {item.series != null ? : null} + {item.episode != null ? : null} +
+ ); +}); + +CarouselItem.displayName = 'CarouselItem'; + export const CarouselSection = ({ module }: Props) => { const containerRefForScrollSnap = useScrollSnap({ scrollPadding: 24 }); const { ref: containerRefForItemWidth, width: itemWidth } = useCarouselItemWidth(); @@ -29,10 +48,7 @@ export const CarouselSection = ({ module }: Props) => { data-scroll-restore={`carousel-${module.id}`} > {module.items.map((item) => ( -
- {item.series != null ? : null} - {item.episode != null ? : null} -
+ ))}
diff --git a/workspaces/client/src/features/recommended/components/EpisodeItem.tsx b/workspaces/client/src/features/recommended/components/EpisodeItem.tsx index 74b08745c..36dba525a 100644 --- a/workspaces/client/src/features/recommended/components/EpisodeItem.tsx +++ b/workspaces/client/src/features/recommended/components/EpisodeItem.tsx @@ -1,8 +1,10 @@ +import { useRef } from 'react'; import Ellipsis from 'react-ellipsis-component'; import { Flipped } from 'react-flip-toolkit'; import { NavLink } from 'react-router'; import { Hoverable } from '@wsh-2025/client/src/features/layout/components/Hoverable'; +import { useIntersectionObserver } from '@wsh-2025/client/src/features/recommended/hooks/useIntersectionObserver'; interface Props { episode: { @@ -17,6 +19,9 @@ interface Props { } export const EpisodeItem = ({ episode }: Props) => { + const containerRef = useRef(null); + const isVisible = useIntersectionObserver(containerRef); + return ( @@ -24,8 +29,20 @@ export const EpisodeItem = ({ episode }: Props) => { return ( <> -
- +
+ {isVisible ? ( + + ) : ( +
+ )} {episode.premium ? ( diff --git a/workspaces/client/src/features/recommended/components/JumbotronSection.tsx b/workspaces/client/src/features/recommended/components/JumbotronSection.tsx index 638c9cfd6..3e6aaf73b 100644 --- a/workspaces/client/src/features/recommended/components/JumbotronSection.tsx +++ b/workspaces/client/src/features/recommended/components/JumbotronSection.tsx @@ -1,6 +1,6 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Ellipsis from 'react-ellipsis-component'; import { Flipped } from 'react-flip-toolkit'; import { NavLink } from 'react-router'; @@ -19,13 +19,49 @@ interface Props { export const JumbotronSection = ({ module }: Props) => { const playerRef = useRef(null); + const containerRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const [isPlayerLoaded, setIsPlayerLoaded] = useState(false); const episode = module.items[0]?.episode; invariant(episode); + // IntersectionObserverを使用して要素が表示されているかを監視 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { threshold: 0.1 } + ); + + observer.observe(containerRef.current); + + return () => { + observer.disconnect(); + }; + }, []); + + // 表示されてから少し遅延させてプレーヤーを読み込む + useEffect(() => { + if (isVisible && !isPlayerLoaded) { + const timer = setTimeout(() => { + setIsPlayerLoaded(true); + }, 100); + + return () => { clearTimeout(timer); }; + } + }, [isVisible, isPlayerLoaded]); + return ( {
- + {isPlayerLoaded ? ( + + ) : ( +
+ {/*
*/} + +
+ )}
diff --git a/workspaces/client/src/features/recommended/components/SeriesItem.tsx b/workspaces/client/src/features/recommended/components/SeriesItem.tsx index 2477b7a97..40810b506 100644 --- a/workspaces/client/src/features/recommended/components/SeriesItem.tsx +++ b/workspaces/client/src/features/recommended/components/SeriesItem.tsx @@ -1,8 +1,10 @@ +import { useRef } from 'react'; import Ellipsis from 'react-ellipsis-component'; import { Flipped } from 'react-flip-toolkit'; import { NavLink } from 'react-router'; import { Hoverable } from '@wsh-2025/client/src/features/layout/components/Hoverable'; +import { useIntersectionObserver } from '@wsh-2025/client/src/features/recommended/hooks/useIntersectionObserver'; interface Props { series: { @@ -13,15 +15,30 @@ interface Props { } export const SeriesItem = ({ series }: Props) => { + const containerRef = useRef(null); + const isVisible = useIntersectionObserver(containerRef); + return ( {({ isTransitioning }) => { return ( <> -
+
- + {isVisible ? ( + + ) : ( +
+ )}
diff --git a/workspaces/client/src/features/recommended/hooks/useCarouselItemWidth.ts b/workspaces/client/src/features/recommended/hooks/useCarouselItemWidth.ts index 83a6afe94..5dbd4e67e 100644 --- a/workspaces/client/src/features/recommended/hooks/useCarouselItemWidth.ts +++ b/workspaces/client/src/features/recommended/hooks/useCarouselItemWidth.ts @@ -1,31 +1,45 @@ -import { useEffect, useRef } from 'react'; -import { useUpdate } from 'react-use'; +import { useEffect, useRef, useState } from 'react'; const MIN_WIDTH = 276; const GAP = 12; // repeat(auto-fill, minmax(276px, 1fr)) を計算で求める export function useCarouselItemWidth() { - const forceUpdate = useUpdate(); + const [itemWidth, setItemWidth] = useState(MIN_WIDTH); const containerRef = useRef(null); useEffect(() => { - const interval = setInterval(function tick() { - forceUpdate(); - }, 250); - return () => { - clearInterval(interval); + if (!containerRef.current) return; + + // 初期サイズを計算 + const calculateWidth = () => { + if (!containerRef.current) return; + + const styles = window.getComputedStyle(containerRef.current); + const innerWidth = containerRef.current.clientWidth - + parseInt(styles.paddingLeft) - parseInt(styles.paddingRight); + + const itemCount = Math.max(1, Math.floor((innerWidth + GAP) / (MIN_WIDTH + GAP))); + const newItemWidth = Math.floor((innerWidth + GAP) / itemCount - GAP); + + setItemWidth(newItemWidth); }; - }, []); - if (containerRef.current == null) { - return { ref: containerRef, width: MIN_WIDTH }; - } + // 初期計算 + calculateWidth(); - const styles = window.getComputedStyle(containerRef.current); - const innerWidth = containerRef.current.clientWidth - parseInt(styles.paddingLeft) - parseInt(styles.paddingRight); - const itemCount = Math.max(0, Math.floor((innerWidth + GAP) / (MIN_WIDTH + GAP))); - const itemWidth = Math.floor((innerWidth + GAP) / itemCount - GAP); + // ResizeObserverを設定 + const resizeObserver = new ResizeObserver(() => { + calculateWidth(); + }); + + resizeObserver.observe(containerRef.current); + + // クリーンアップ + return () => { + resizeObserver.disconnect(); + }; + }, []); return { ref: containerRef, width: itemWidth }; } diff --git a/workspaces/client/src/features/recommended/hooks/useIntersectionObserver.ts b/workspaces/client/src/features/recommended/hooks/useIntersectionObserver.ts new file mode 100644 index 000000000..787921113 --- /dev/null +++ b/workspaces/client/src/features/recommended/hooks/useIntersectionObserver.ts @@ -0,0 +1,40 @@ +import { RefObject, useEffect, useState } from 'react'; + +/** + * 要素が表示されているかを監視するカスタムフック + * 複数のコンポーネントで共有することで、IntersectionObserverの生成を最適化 + */ +export function useIntersectionObserver( + elementRef: RefObject, + options: IntersectionObserverInit = { threshold: 0.1, rootMargin: '100px' } +): boolean { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + // 要素がない場合は何もしない + if (!elementRef.current) return; + + // 既に表示されている場合は何もしない + if (isVisible) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry && entry.isIntersecting) { + setIsVisible(true); + // 一度表示されたら監視を停止 + observer.disconnect(); + } + }, + options + ); + + observer.observe(elementRef.current); + + return () => { + observer.disconnect(); + }; + }, [elementRef, isVisible, options]); + + return isVisible; +} \ No newline at end of file diff --git a/workspaces/client/src/features/recommended/hooks/useScrollSnap.ts b/workspaces/client/src/features/recommended/hooks/useScrollSnap.ts index e6cb168e7..fdef449ba 100644 --- a/workspaces/client/src/features/recommended/hooks/useScrollSnap.ts +++ b/workspaces/client/src/features/recommended/hooks/useScrollSnap.ts @@ -1,9 +1,15 @@ import { useEffect, useRef } from 'react'; +import { debounce } from '@wsh-2025/client/src/utils/debounce'; + export function useScrollSnap({ scrollPadding }: { scrollPadding: number }) { const containerRef = useRef(null); const isScrolling = useRef(false); const isSnapping = useRef(false); + // 最後に計算した子要素の位置をキャッシュ + const childPositionsCache = useRef([]); + // 子要素の数が変わったかどうかを追跡 + const childCountRef = useRef(0); useEffect(() => { if (containerRef.current == null) { @@ -22,41 +28,64 @@ export function useScrollSnap({ scrollPadding }: { scrollPadding: number }) { return; } isScrolling.current = false; + + // スクロール終了時にスナップ処理を実行 + if (!isSnapping.current && containerRef.current) { + performScrollSnap(); + } }; - let timer: ReturnType | null = null; - let interval = setInterval(() => { - if (!containerRef.current) { + // 子要素の位置を計算し、キャッシュする関数 + const updateChildPositions = () => { + if (!containerRef.current) return; + + const childElements = containerRef.current.children; + // 子要素の数が変わっていない場合はキャッシュを再利用 + if (childElements.length === childCountRef.current && childPositionsCache.current.length > 0) { return; } - const childElements = Array.from(containerRef.current.children) as HTMLElement[]; - const childScrollPositions = childElements.map((element) => element.offsetLeft); + // 子要素の数が変わった場合は位置を再計算 + childCountRef.current = childElements.length; + childPositionsCache.current = Array.from(childElements).map( + (element) => (element as HTMLElement).offsetLeft + ); + }; + + // スクロールスナップ処理をdebounceで最適化 + const performScrollSnap = debounce(() => { + if (!containerRef.current || isSnapping.current) return; + + isSnapping.current = true; + + // 子要素の位置を更新 + updateChildPositions(); + const scrollPosition = containerRef.current.scrollLeft; - const childIndex = childScrollPositions.reduce((prev, curr, index) => { - return Math.abs(curr - scrollPosition) < Math.abs((childScrollPositions[prev] ?? 0) - scrollPosition) - ? index - : prev; - }, 0); - if (isScrolling.current) { - return; - } + // 最も近い子要素のインデックスを見つける(バイナリサーチを使用) + let closestIndex = 0; + let minDistance = Number.MAX_VALUE; - if (isSnapping.current) { - return; + // 子要素が少ない場合は線形探索、多い場合はより効率的なアルゴリズムを検討 + for (let i = 0; i < childPositionsCache.current.length; i++) { + const distance = Math.abs(childPositionsCache.current[i] - scrollPosition); + if (distance < minDistance) { + minDistance = distance; + closestIndex = i; + } } - isSnapping.current = true; containerRef.current.scrollTo({ behavior: 'smooth', - left: (childScrollPositions[childIndex] ?? 0) - scrollPadding, + left: (childPositionsCache.current[closestIndex] ?? 0) - scrollPadding, }); - timer = setTimeout(() => { + // スナップ完了後にフラグをリセット + setTimeout(() => { isSnapping.current = false; - }, 1000); - }); + }, 500); // スムーススクロールの完了を待つ + }, 100); // 100msのdebounce containerRef.current.addEventListener('scroll', handleScroll); containerRef.current.addEventListener('scrollend', handleScrollend); @@ -64,12 +93,9 @@ export function useScrollSnap({ scrollPadding }: { scrollPadding: number }) { return () => { containerRef.current?.removeEventListener('scroll', handleScroll); containerRef.current?.removeEventListener('scrollend', handleScrollend); - clearInterval(interval); - if (timer) { - clearTimeout(timer); - } + performScrollSnap.cancel(); // debounceされた関数をキャンセル }; - }, []); + }, [scrollPadding]); return containerRef; } diff --git a/workspaces/client/src/features/recommended/services/recommendedService.ts b/workspaces/client/src/features/recommended/services/recommendedService.ts index 494ad911f..ae91e7ca5 100644 --- a/workspaces/client/src/features/recommended/services/recommendedService.ts +++ b/workspaces/client/src/features/recommended/services/recommendedService.ts @@ -11,6 +11,9 @@ const $fetch = createFetch({ '/recommended/:referenceId': { output: schema.getRecommendedModulesResponse, }, + '/recommended/error': { + output: schema.getRecommendedModulesErrorResponse, + }, }), throw: true, }); @@ -18,11 +21,16 @@ const $fetch = createFetch({ interface RecommendedService { fetchRecommendedModulesByReferenceId: (params: { referenceId: string; - }) => Promise>; + }) => Promise | StandardSchemaV1.InferOutput>; } export const recommendedService: RecommendedService = { async fetchRecommendedModulesByReferenceId({ referenceId }) { + if (referenceId === 'error') { + const data = await $fetch('/recommended/error', {}); + return data; + } + const data = await $fetch('/recommended/:referenceId', { params: { referenceId }, }); diff --git a/workspaces/client/src/features/requests/schedulePlugin.ts b/workspaces/client/src/features/requests/schedulePlugin.ts index 0c6cb6c6d..941d92222 100644 --- a/workspaces/client/src/features/requests/schedulePlugin.ts +++ b/workspaces/client/src/features/requests/schedulePlugin.ts @@ -2,18 +2,9 @@ import type { BetterFetchPlugin } from '@better-fetch/fetch'; export const schedulePlugin = { hooks: { - onRequest: async (request) => { - const scheduler = typeof window !== 'undefined' ? window.scheduler : undefined; - - if (scheduler) { - return await scheduler.postTask(() => request, { delay: 1000 }); - } else { - return await new Promise((resolve) => { - queueMicrotask(() => { - resolve(request); - }); - }); - } + onRequest: (request) => { + // 遅延なしで即時実行 + return request; }, }, id: 'schedulePlugin', diff --git a/workspaces/client/src/features/series/components/SeriesEpisodeList.tsx b/workspaces/client/src/features/series/components/SeriesEpisodeList.tsx index 4e258eecb..153490d59 100644 --- a/workspaces/client/src/features/series/components/SeriesEpisodeList.tsx +++ b/workspaces/client/src/features/series/components/SeriesEpisodeList.tsx @@ -13,13 +13,14 @@ interface Props { } export const SeriesEpisodeList = ({ episodes, selectedEpisodeId }: Props) => { - const orderedEpisodes = [...episodes].sort((a, b) => { - return a.order - b.order; - }); + // memo: サーバでソート済みっぽい + // const orderedEpisodes = [...episodes].sort((a, b) => { + // return a.order - b.order; + // }); return (
- {orderedEpisodes.map((episode) => ( + {episodes.map((episode) => (
diff --git a/workspaces/client/src/features/series/components/SeriesEposideItem.tsx b/workspaces/client/src/features/series/components/SeriesEposideItem.tsx index 0a0067d95..d3094b57a 100644 --- a/workspaces/client/src/features/series/components/SeriesEposideItem.tsx +++ b/workspaces/client/src/features/series/components/SeriesEposideItem.tsx @@ -28,7 +28,7 @@ export const SeriesEpisodeItem = ({ episode, selected }: Props) => { <>
- + {episode.premium ? ( diff --git a/workspaces/client/src/main.tsx b/workspaces/client/src/main.tsx index 601b7a6a5..27f5b48b7 100644 --- a/workspaces/client/src/main.tsx +++ b/workspaces/client/src/main.tsx @@ -17,10 +17,22 @@ declare global { function main() { const store = createStore({}); - const router = createBrowserRouter(createRoutes(store), {}); + const router = createBrowserRouter(createRoutes(store), { + future: { + // v7_normalizeFormMethod: true, + v7_partialHydration: true, + }, + hydrationData: window.__staticRouterHydrationData, + }); + + const rootElement = document.getElementById('app-root'); + if (!rootElement) { + console.error('Root element #app-root not found'); + return; + } hydrateRoot( - document, + rootElement, store}> diff --git a/workspaces/client/src/pages/episode/components/EpisodePage.tsx b/workspaces/client/src/pages/episode/components/EpisodePage.tsx index 075eacc48..8ca5d3c43 100644 --- a/workspaces/client/src/pages/episode/components/EpisodePage.tsx +++ b/workspaces/client/src/pages/episode/components/EpisodePage.tsx @@ -1,3 +1,4 @@ +// import { AspectRatio } from '@wsh-2025/client/src/features/layout/components/AspectRatio'; import { Suspense } from 'react'; import Ellipsis from 'react-ellipsis-component'; import { Flipped } from 'react-flip-toolkit'; @@ -8,7 +9,7 @@ import { createStore } from '@wsh-2025/client/src/app/createStore'; import { useAuthActions } from '@wsh-2025/client/src/features/auth/hooks/useAuthActions'; import { useAuthUser } from '@wsh-2025/client/src/features/auth/hooks/useAuthUser'; import { useEpisodeById } from '@wsh-2025/client/src/features/episode/hooks/useEpisodeById'; -import { AspectRatio } from '@wsh-2025/client/src/features/layout/components/AspectRatio'; +import { useSubscribePointer } from '@wsh-2025/client/src/features/layout/hooks/useSubscribePointer'; import { Player } from '@wsh-2025/client/src/features/player/components/Player'; import { PlayerType } from '@wsh-2025/client/src/features/player/constants/player_type'; import { RecommendedSection } from '@wsh-2025/client/src/features/recommended/components/RecommendedSection'; @@ -20,13 +21,15 @@ import { usePlayerRef } from '@wsh-2025/client/src/pages/episode/hooks/usePlayer export const prefetch = async (store: ReturnType, { episodeId }: Params) => { invariant(episodeId); const episode = await store.getState().features.episode.fetchEpisodeById({ episodeId }); - const modules = await store - .getState() - .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: episodeId }); - return { episode, modules }; + // const modules = await store + // .getState() + // .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: episodeId }); + return { episode }; }; - export const EpisodePage = () => { + // ポインター位置の追跡を開始(SeekThumbnailで使用) + useSubscribePointer(); + const authActions = useAuthActions(); const user = useAuthUser(); @@ -51,7 +54,7 @@ export const EpisodePage = () => {
{isSignInRequired ? (
- +

@@ -69,17 +72,26 @@ export const EpisodePage = () => { ) : ( +

-
+ + + {/*
*/}
- +
} >
diff --git a/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx b/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx index b706a379b..c3e28675d 100644 --- a/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx +++ b/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx @@ -1,6 +1,6 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import { useRef } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { usePointer } from '@wsh-2025/client/src/features/layout/hooks/usePointer'; import { useDuration } from '@wsh-2025/client/src/pages/episode/hooks/useDuration'; @@ -28,12 +28,36 @@ export const SeekThumbnail = ({ episode }: Props) => { const MIN_LEFT = SEEK_THUMBNAIL_WIDTH / 2; const MAX_LEFT = elementRect.width - SEEK_THUMBNAIL_WIDTH / 2; + // サムネイルが読み込まれていない場合はローディング表示 + if (!seekThumbnail) { + return ( +
+ Loading... +
+ ); + } +// サムネイルの位置を計算 +// サーバーサイドで生成されたサムネイルは複数枚のサムネイルが横に並んだ画像 +// 固定の数(50枚)を使用する +const thumbnailCount = 50; // 最大サムネイル数 + +// パーセンテージに基づいてサムネイルインデックスを計算 +const thumbnailIndex = Math.min(thumbnailCount - 1, Math.floor(percentage * thumbnailCount)); + return (
diff --git a/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts b/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts index 8d0015d89..452997431 100644 --- a/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts +++ b/workspaces/client/src/pages/episode/hooks/useSeekThumbnail.ts @@ -1,76 +1,44 @@ -import { FFmpeg } from '@ffmpeg/ffmpeg'; import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import { Parser } from 'm3u8-parser'; -import { use } from 'react'; +import { useState, useEffect } from 'react'; interface Params { episode: StandardSchemaV1.InferOutput; } -async function getSeekThumbnail({ episode }: Params) { - // HLS のプレイリストを取得 - const playlistUrl = `/streams/episode/${episode.id}/playlist.m3u8`; - const parser = new Parser(); - parser.push(await fetch(playlistUrl).then((res) => res.text())); - parser.end(); - - // FFmpeg の初期化 - const ffmpeg = new FFmpeg(); - await ffmpeg.load({ - coreURL: await import('@ffmpeg/core?arraybuffer').then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: 'text/javascript' })); - }), - wasmURL: await import('@ffmpeg/core/wasm?arraybuffer').then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: 'application/wasm' })); - }), - }); - - // 動画のセグメントファイルを取得 - const segmentFiles = await Promise.all( - parser.manifest.segments.map((s) => { - return fetch(s.uri).then(async (res) => { - const binary = await res.arrayBuffer(); - return { binary, id: Math.random().toString(36).slice(2) }; +/** + * サーバーサイドで生成されたサムネイル画像を取得するフック + * + * @param episode エピソード情報 + * @returns サムネイル画像のURL + */ +export const useSeekThumbnail = ({ episode }: Params): string => { + const [thumbnailUrl, setThumbnailUrl] = useState(''); + + + + useEffect(() => { + // サーバーから直接サムネイル画像のURLを取得 + const url = `/thumbnails/episode/${episode.streamId}/preview.jpg`; + + // 画像が存在するか確認 + fetch(url, { method: 'HEAD' }) + .then(response => { + if (response.ok) { + setThumbnailUrl(url); + } else { + // 画像が存在しない場合は生成をリクエスト + return fetch(url).then(() => url); + } + return url; + }) + .then(finalUrl => { + setThumbnailUrl(finalUrl); + }) + .catch((error: unknown) => { + console.error('Error fetching thumbnail:', error); }); - }), - ); - // FFmpeg にセグメントファイルを追加 - for (const file of segmentFiles) { - await ffmpeg.writeFile(file.id, new Uint8Array(file.binary)); - } - - // セグメントファイルをひとつの mp4 動画に結合 - await ffmpeg.exec( - [ - ['-i', `concat:${segmentFiles.map((f) => f.id).join('|')}`], - ['-c:v', 'copy'], - ['-map', '0:v:0'], - ['-f', 'mp4'], - 'concat.mp4', - ].flat(), - ); - - // fps=30 とみなして、30 フレームごと(1 秒ごと)にサムネイルを生成 - await ffmpeg.exec( - [ - ['-i', 'concat.mp4'], - ['-vf', "fps=30,select='not(mod(n\\,30))',scale=160:90,tile=250x1"], - ['-frames:v', '1'], - 'preview.jpg', - ].flat(), - ); + }, [episode.id]); - const output = await ffmpeg.readFile('preview.jpg'); - ffmpeg.terminate(); - - return URL.createObjectURL(new Blob([output], { type: 'image/jpeg' })); -} - -const weakMap = new WeakMap>(); - -export const useSeekThumbnail = ({ episode }: Params): string => { - const promise = weakMap.get(episode) ?? getSeekThumbnail({ episode }); - weakMap.set(episode, promise); - return use(promise); + return thumbnailUrl; }; diff --git a/workspaces/client/src/pages/not_found/components/NotFoundPage.tsx b/workspaces/client/src/pages/not_found/components/NotFoundPage.tsx index 66c861769..0efdcad00 100644 --- a/workspaces/client/src/pages/not_found/components/NotFoundPage.tsx +++ b/workspaces/client/src/pages/not_found/components/NotFoundPage.tsx @@ -1,18 +1,24 @@ -import { createStore } from '@wsh-2025/client/src/app/createStore'; +import { useEffect } from 'react'; + +import { useStore } from '@wsh-2025/client/src/app/StoreContext'; import { RecommendedSection } from '@wsh-2025/client/src/features/recommended/components/RecommendedSection'; import { useRecommended } from '@wsh-2025/client/src/features/recommended/hooks/useRecommended'; -export const prefetch = async (store: ReturnType) => { - const modules = await store - .getState() - .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: 'error' }); - return { modules }; +// ローダーがprefetch関数を期待しているため、空の関数を提供 +export const prefetch = () => { + return {}; }; export const NotFoundPage = () => { + const store = useStore((s) => s); const modules = useRecommended({ referenceId: 'error' }); const module = modules.at(0); + // クライアントサイドでのみデータを取得 + useEffect(() => { + void store.features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: 'error' }); + }, [store.features.recommended]); + return ( <> 見つかりません - AremaTV @@ -21,7 +27,7 @@ export const NotFoundPage = () => {

ページが見つかりませんでした

あなたが見ようとしたページは、残念ながら見つけられませんでした。

- +
{module != null ? : null}
diff --git a/workspaces/client/src/pages/program/components/PlayerController.tsx b/workspaces/client/src/pages/program/components/PlayerController.tsx index 6a1c7fef0..c8cc35528 100644 --- a/workspaces/client/src/pages/program/components/PlayerController.tsx +++ b/workspaces/client/src/pages/program/components/PlayerController.tsx @@ -11,7 +11,18 @@ export const PlayerController = () => {
- + + + ライブ配信
diff --git a/workspaces/client/src/pages/program/components/ProgramPage.tsx b/workspaces/client/src/pages/program/components/ProgramPage.tsx index d13dc9426..ac93ebbdb 100644 --- a/workspaces/client/src/pages/program/components/ProgramPage.tsx +++ b/workspaces/client/src/pages/program/components/ProgramPage.tsx @@ -20,17 +20,18 @@ import { usePlayerRef } from '@wsh-2025/client/src/pages/program/hooks/usePlayer export const prefetch = async (store: ReturnType, { programId }: Params) => { invariant(programId); - const now = DateTime.now(); - const since = now.startOf('day').toISO(); - const until = now.endOf('day').toISO(); - - const program = await store.getState().features.program.fetchProgramById({ programId }); - const channels = await store.getState().features.channel.fetchChannels(); - const timetable = await store.getState().features.timetable.fetchTimetable({ since, until }); - const modules = await store - .getState() - .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: programId }); - return { channels, modules, program, timetable }; + // const now = DateTime.now(); + // const since = now.startOf('day').toISO(); + // const until = now.endOf('day').toISO(); + + // const [program, channels, timetable, modules] = await Promise.all([ + const [program] = await Promise.all([ + store.getState().features.program.fetchProgramById({ programId }), + // store.getState().features.channel.fetchChannels(), + // store.getState().features.timetable.fetchTimetable({ since, until }), + // store.getState().features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: programId }) + ]); + return { program}; }; export const ProgramPage = () => { @@ -62,8 +63,8 @@ export const ProgramPage = () => { if (!isBroadcastStarted) { let timeout = setTimeout(function tick() { forceUpdate(); - timeout = setTimeout(tick, 250); - }, 250); + timeout = setTimeout(tick, 2000); + }, 2000); return () => { clearTimeout(timeout); }; @@ -72,7 +73,7 @@ export const ProgramPage = () => { // 放送中に次の番組が始まったら、画面をそのままにしつつ、情報を次の番組にする let timeout = setTimeout(function tick() { if (DateTime.now() < DateTime.fromISO(program.endAt)) { - timeout = setTimeout(tick, 250); + timeout = setTimeout(tick, 2000); return; } @@ -86,7 +87,7 @@ export const ProgramPage = () => { isArchivedRef.current = true; forceUpdate(); } - }, 250); + }, 2000); return () => { clearTimeout(timeout); }; @@ -101,7 +102,7 @@ export const ProgramPage = () => {
{isArchivedRef.current ? (
- +

この番組は放送が終了しました

@@ -127,7 +128,7 @@ export const ProgramPage = () => {
) : (
- +

diff --git a/workspaces/client/src/pages/series/components/SeriesPage.tsx b/workspaces/client/src/pages/series/components/SeriesPage.tsx index abf35aee5..a84b26c8d 100644 --- a/workspaces/client/src/pages/series/components/SeriesPage.tsx +++ b/workspaces/client/src/pages/series/components/SeriesPage.tsx @@ -11,11 +11,11 @@ import { useSeriesById } from '@wsh-2025/client/src/features/series/hooks/useSer export const prefetch = async (store: ReturnType, { seriesId }: Params) => { invariant(seriesId); - const series = await store.getState().features.series.fetchSeriesById({ seriesId }); - const modules = await store - .getState() - .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: seriesId }); - return { modules, series }; + const [series] = await Promise.all([ + store.getState().features.series.fetchSeriesById({ seriesId }), + // store.getState().features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: seriesId }), + ]); + return { series }; }; export const SeriesPage = () => { @@ -37,6 +37,7 @@ export const SeriesPage = () => { diff --git a/workspaces/client/src/pages/timetable/components/ChannelTitle.tsx b/workspaces/client/src/pages/timetable/components/ChannelTitle.tsx index c2538c886..41133d213 100644 --- a/workspaces/client/src/pages/timetable/components/ChannelTitle.tsx +++ b/workspaces/client/src/pages/timetable/components/ChannelTitle.tsx @@ -17,7 +17,7 @@ export const ChannelTitle = ({ channelId }: Props) => { return (

- {channel.name} + {channel.name}
diff --git a/workspaces/client/src/pages/timetable/components/NewTimetableFeatureDialog.tsx b/workspaces/client/src/pages/timetable/components/NewTimetableFeatureDialog.tsx index ce0f9224a..2c01a54b3 100644 --- a/workspaces/client/src/pages/timetable/components/NewTimetableFeatureDialog.tsx +++ b/workspaces/client/src/pages/timetable/components/NewTimetableFeatureDialog.tsx @@ -13,7 +13,7 @@ export const NewTimetableFeatureDialog = ({ isOpen }: Props) => {
- +

拡大・縮小機能を新しく追加

@@ -29,7 +29,7 @@ export const NewTimetableFeatureDialog = ({ isOpen }: Props) => { 引き続き皆様に快適にご利用いただけるよう、サービスの改善に努めてまいります。今後ともどうぞよろしくお願いいたします。

- +
diff --git a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx index 41e0ec14c..6824c9656 100644 --- a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx +++ b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx @@ -33,6 +33,7 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => diff --git a/workspaces/client/src/pages/timetable/stores/createTimetablePageStoreSlice.ts b/workspaces/client/src/pages/timetable/stores/createTimetablePageStoreSlice.ts index ed190b39b..1d44e87dd 100644 --- a/workspaces/client/src/pages/timetable/stores/createTimetablePageStoreSlice.ts +++ b/workspaces/client/src/pages/timetable/stores/createTimetablePageStoreSlice.ts @@ -2,10 +2,11 @@ import { lens } from '@dhmk/zustand-lens'; import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; import { produce } from 'immer'; -import _ from 'lodash'; import { ArrayValues } from 'type-fest'; import { DEFAULT_WIDTH } from '@wsh-2025/client/src/features/timetable/constants/grid_size'; +import { debounce } from '@wsh-2025/client/src/utils/debounce'; + type ChannelId = string; type Program = ArrayValues>; @@ -41,7 +42,7 @@ export const createTimetablePageStoreSlice = () => { }, columnWidthRecord: {}, currentUnixtimeMs: 0, - refreshCurrentUnixtimeMs: _.debounce(() => { + refreshCurrentUnixtimeMs: debounce(() => { set(() => ({ currentUnixtimeMs: Date.now(), })); diff --git a/workspaces/client/src/setups/polyfills.ts b/workspaces/client/src/setups/polyfills.ts index 0077ca803..5149e0d2f 100644 --- a/workspaces/client/src/setups/polyfills.ts +++ b/workspaces/client/src/setups/polyfills.ts @@ -1,3 +1 @@ -import 'core-js'; -import 'view-transitions-polyfill'; -import 'setimmediate'; +// このファイルは空にします diff --git a/workspaces/client/src/setups/unocss.ts b/workspaces/client/src/setups/unocss.ts index d8e91da31..398d3dd96 100644 --- a/workspaces/client/src/setups/unocss.ts +++ b/workspaces/client/src/setups/unocss.ts @@ -49,15 +49,14 @@ async function init() { presetWind3(), presetIcons({ collections: { - bi: () => import('@iconify/json/json/bi.json').then((m): IconifyJSON => m.default as IconifyJSON), - bx: () => import('@iconify/json/json/bx.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'fa-regular': () => - import('@iconify/json/json/fa-regular.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'fa-solid': () => - import('@iconify/json/json/fa-solid.json').then((m): IconifyJSON => m.default as IconifyJSON), - fluent: () => import('@iconify/json/json/fluent.json').then((m): IconifyJSON => m.default as IconifyJSON), - 'line-md': () => - import('@iconify/json/json/line-md.json').then((m): IconifyJSON => m.default as IconifyJSON), + // bi: () => import('@iconify/json/json/bi.json').then((m): IconifyJSON => m.default as IconifyJSON), + // bx: () => import('@iconify/json/json/bx.json').then((m): IconifyJSON => m.default as IconifyJSON), + // 'fa-regular': () => + // import('@iconify/json/json/fa-regular.json').then((m): IconifyJSON => m.default as IconifyJSON), + // 'fa-solid': () => + // import('@iconify/json/json/fa-solid.json').then((m): IconifyJSON => m.default as IconifyJSON), + // 'line-md': () => + // import('@iconify/json/json/line-md.json').then((m): IconifyJSON => m.default as IconifyJSON), 'material-symbols': () => import('@iconify/json/json/material-symbols.json').then((m): IconifyJSON => m.default as IconifyJSON), }, diff --git a/workspaces/client/src/utils/debounce.ts b/workspaces/client/src/utils/debounce.ts new file mode 100644 index 000000000..6339db9ae --- /dev/null +++ b/workspaces/client/src/utils/debounce.ts @@ -0,0 +1,95 @@ +export interface DebouncedFunction void> { + (...args: Parameters): void; + cancel: () => void; + flush: () => void; + pending: () => boolean; +} + +export interface DebounceOptions { + immediate?: boolean; +} + +export function debounce void>( + func: T, + wait: number, + options: DebounceOptions = {} +): DebouncedFunction { + let timeoutId: ReturnType | undefined; + let lastArgs: Parameters | undefined; + let lastCallTime: number | undefined; + + function invokeFunc(thisArg: ThisParameterType): void { + const args = lastArgs; + lastArgs = undefined; + func.apply(thisArg, args ?? []); + } + + function shouldInvoke(time: number): boolean { + const timeSinceLastCall = time - (lastCallTime ?? 0); + return lastCallTime === undefined || timeSinceLastCall >= wait; + } + + function trailingEdge(_time: number, thisArg: ThisParameterType): void { + timeoutId = undefined; + + if (lastArgs) { + invokeFunc(thisArg); + } + } + + function createTimerExpired(thisArg: ThisParameterType) { + return function timerExpired(): void { + const time = Date.now(); + if (shouldInvoke(time)) { + trailingEdge(time, thisArg); + return; + } + // Restart the timer + const timeSinceLastCall = time - (lastCallTime ?? 0); + const timeWaiting = wait - timeSinceLastCall; + timeoutId = setTimeout(timerExpired, timeWaiting); + }; + } + + function debounced(this: ThisParameterType, ...args: Parameters): void { + const time = Date.now(); + lastArgs = args; + lastCallTime = time; + + if (timeoutId === undefined) { + if (options.immediate) { + func.apply(this, args); + } else { + timeoutId = setTimeout(createTimerExpired(this), wait); + } + return; + } + + if (!options.immediate) { + clearTimeout(timeoutId); + timeoutId = setTimeout(createTimerExpired(this), wait); + } + } + + debounced.cancel = function(): void { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + lastArgs = undefined; + lastCallTime = undefined; + }; + + debounced.flush = function(this: ThisParameterType): void { + if (timeoutId !== undefined) { + trailingEdge(Date.now(), this); + debounced.cancel(); + } + }; + + debounced.pending = function(): boolean { + return timeoutId !== undefined; + }; + + return debounced; +} \ No newline at end of file diff --git a/workspaces/client/webpack.config.mjs b/workspaces/client/webpack.config.mjs index 9164a996e..7db7af2de 100644 --- a/workspaces/client/webpack.config.mjs +++ b/workspaces/client/webpack.config.mjs @@ -1,12 +1,16 @@ import path from 'node:path'; import webpack from 'webpack'; +// import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer' + /** @type {import('webpack').Configuration} */ const config = { devtool: 'inline-source-map', + // devtool: false, entry: './src/main.tsx', - mode: 'none', + // mode: 'none', + mode: 'production', module: { rules: [ { @@ -18,16 +22,17 @@ const config = { use: { loader: 'babel-loader', options: { + cacheDirectory: true, presets: [ - [ - '@babel/preset-env', - { - corejs: '3.41', - forceAllTransforms: true, - targets: 'defaults', - useBuiltIns: 'entry', - }, - ], + // [ + // '@babel/preset-env', + // { + // corejs: '3.41', + // forceAllTransforms: true, + // targets: 'defaults', + // useBuiltIns: 'entry', + // }, + // ], ['@babel/preset-react', { runtime: 'automatic' }], ['@babel/preset-typescript'], ], @@ -51,16 +56,22 @@ const config = { }, ], }, + optimization: { + usedExports: true, + }, output: { chunkFilename: 'chunk-[contenthash].js', - chunkFormat: false, + // chunkFormat: false, + chunkFormat: "module", filename: 'main.js', path: path.resolve(import.meta.dirname, './dist'), publicPath: 'auto', }, plugins: [ - new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), - new webpack.EnvironmentPlugin({ API_BASE_URL: '/api', NODE_ENV: '' }), + // new BundleAnalyzerPlugin(), + // new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), + new webpack.EnvironmentPlugin({ API_BASE_URL: '/api', NODE_ENV: 'production' }), + // new BundleAnalyzerPlugin(), ], resolve: { alias: { diff --git a/workspaces/schema/src/api/schema.ts b/workspaces/schema/src/api/schema.ts index ed327fd43..618392ed0 100644 --- a/workspaces/schema/src/api/schema.ts +++ b/workspaces/schema/src/api/schema.ts @@ -21,7 +21,9 @@ export const getProgramsResponse = Compile(Valibot(openapiSchema.getProgramsResp export const getProgramByIdRequestParams = Compile(Valibot(openapiSchema.getProgramByIdRequestParams)); export const getProgramByIdResponse = Compile(Valibot(openapiSchema.getProgramByIdResponse)); export const getRecommendedModulesRequestParams = Compile(Valibot(openapiSchema.getRecommendedModulesRequestParams)); -export const getRecommendedModulesResponse = Compile(Valibot(openapiSchema.getRecommendedModulesResponse)); +// ↓ここ変えてる +export const getRecommendedModulesResponse = Compile(Valibot(openapiSchema.getRecommendedModulesErrorResponse)); +export const getRecommendedModulesErrorResponse = Compile(Valibot(openapiSchema.getRecommendedModulesErrorResponse)); export const signInRequestBody = Compile(Zod(openapiSchema.signInRequestBody)); export const signInResponse = Compile(Zod(openapiSchema.signInResponse)); export const signUpRequestBody = Compile(Zod(openapiSchema.signUpRequestBody)); diff --git a/workspaces/schema/src/openapi/schema.ts b/workspaces/schema/src/openapi/schema.ts index fb64a2f3d..3eb69b467 100644 --- a/workspaces/schema/src/openapi/schema.ts +++ b/workspaces/schema/src/openapi/schema.ts @@ -199,6 +199,48 @@ export const getRecommendedModulesResponse = z.array( }), ); +export const getRecommendedModulesErrorResponse = z.array( + z.object({ + id: z.string().openapi({ format: 'uuid' }), + title: z.string().openapi({ example: '吾輩は猫である' }), + type: z.enum(['carousel', 'jumbotron']).openapi({ example: 'carousel' }), + items: z.array( + z.object({ + id: z.string().openapi({ format: 'uuid' }), + order: z.number().openapi({ example: 1 }), + series: z + .object({ + id: z.string().openapi({ format: 'uuid' }), + title: z.string().openapi({ example: '吾輩は猫である' }), + thumbnailUrl: z.string().openapi({ + example: 'https://image.example.com/assets/d13d2e22-a7ff-44ba-94a3-5f025f2b63cd.png', + }), + }) + .nullable(), + episode: z + .object({ + id: z.string().openapi({ format: 'uuid' }), + title: z.string().openapi({ example: '第1話 吾輩は猫である' }), + description: z.string().openapi({ + example: + '『吾輩は猫である』(わがはいはねこである)は、夏目漱石の長編小説であり、処女小説である。', + }), + premium: z.boolean().openapi({ example: false }), + thumbnailUrl: z.string().openapi({ + example: 'https://image.example.com/assets/d13d2e22-a7ff-44ba-94a3-5f025f2b63cd.png', + }), + series: z + .object({ + title: z.string().openapi({ example: '吾輩は猫である' }), + }) + .nullable(), + }) + .nullable(), + }), + ), + }), +); + // POST /signIn export const signInRequestBody = z.object({ email: z.string(), diff --git a/workspaces/server/database.sqlite b/workspaces/server/database.sqlite index 5afaf4e28..528ae698b 100644 Binary files a/workspaces/server/database.sqlite and b/workspaces/server/database.sqlite differ diff --git a/workspaces/server/package.json b/workspaces/server/package.json index 456f1fbd3..fea40fee0 100644 --- a/workspaces/server/package.json +++ b/workspaces/server/package.json @@ -2,6 +2,7 @@ "name": "@wsh-2025/server", "private": true, "scripts": { + "create-thumbnails": "bash ./tools/create_default_thumbnails.sh", "database:migrate": "wireit", "database:reset": "wireit", "format": "wireit", @@ -109,4 +110,4 @@ "command": "prettier --write ." } } -} +} \ No newline at end of file diff --git a/workspaces/server/src/api.ts b/workspaces/server/src/api.ts index 635624b1f..cee8a9bf7 100644 --- a/workspaces/server/src/api.ts +++ b/workspaces/server/src/api.ts @@ -449,18 +449,16 @@ export async function registerApi(app: FastifyInstance): Promise { reply.code(200).send(program); }, }); - api.route({ method: 'GET', - url: '/recommended/:referenceId', + url: '/recommended/error', schema: { tags: ['レコメンド'], - params: schema.getRecommendedModulesRequestParams, response: { 200: { content: { 'application/json': { - schema: schema.getRecommendedModulesResponse, + schema: schema.getRecommendedModulesErrorResponse, }, }, }, @@ -473,8 +471,15 @@ export async function registerApi(app: FastifyInstance): Promise { orderBy(module, { asc }) { return asc(module.order); }, + // TODO: このwhereいる? where(module, { eq }) { - return eq(module.referenceId, req.params.referenceId); + const a =eq(module.referenceId, 'error'); + return a + }, + columns: { + id: true, + title: true, + type: true, }, with: { items: { @@ -483,23 +488,93 @@ export async function registerApi(app: FastifyInstance): Promise { }, with: { series: { + columns: { + id: true, + title: true, + thumbnailUrl: true, + } + }, + episode: { + columns: { + id: true, + title: true, + description: true, + premium: true, + thumbnailUrl: true, + }, with: { - episodes: { - orderBy(episode, { asc }) { - return asc(episode.order); + series: { + columns: { + title: true, }, }, }, }, + }, + }, + }, + }); + reply.code(200).send(modules); + }, + }) + + + api.route({ + method: 'GET', + url: '/recommended/:referenceId', + schema: { + tags: ['レコメンド'], + params: schema.getRecommendedModulesRequestParams, + response: { + 200: { + content: { + 'application/json': { + schema: schema.getRecommendedModulesErrorResponse, + }, + }, + }, + }, + } satisfies FastifyZodOpenApiSchema, + handler: async function getRecommendedModules(req, reply) { + const database = getDatabase(); + + const modules = await database.query.recommendedModule.findMany({ + orderBy(module, { asc }) { + return asc(module.order); + }, + where(module, { eq }) { + return eq(module.referenceId, req.params.referenceId); + }, + columns: { + id: true, + title: true, + type: true, + }, + with: { + items: { + orderBy(item, { asc }) { + return asc(item.order); + }, + with: { + series: { + columns: { + id: true, + title: true, + thumbnailUrl: true, + } + }, episode: { + columns: { + id: true, + title: true, + description: true, + premium: true, + thumbnailUrl: true, + }, with: { series: { - with: { - episodes: { - orderBy(episode, { asc }) { - return asc(episode.order); - }, - }, + columns: { + title: true, }, }, }, diff --git a/workspaces/server/src/ssr.tsx b/workspaces/server/src/ssr.tsx index 7603aafdd..03b59ef3f 100644 --- a/workspaces/server/src/ssr.tsx +++ b/workspaces/server/src/ssr.tsx @@ -1,4 +1,3 @@ -import { readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,17 +12,17 @@ import { StrictMode } from 'react'; import { renderToString } from 'react-dom/server'; import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router'; -function getFiles(parent: string): string[] { - const dirents = readdirSync(parent, { withFileTypes: true }); - return dirents - .filter((dirent) => dirent.isFile() && !dirent.name.startsWith('.')) - .map((dirent) => path.join(parent, dirent.name)); -} +// function getFiles(parent: string): string[] { +// const dirents = readdirSync(parent, { withFileTypes: true }); +// return dirents +// .filter((dirent) => dirent.isFile() && !dirent.name.startsWith('.')) +// .map((dirent) => path.join(parent, dirent.name)); +// } -function getFilePaths(relativePath: string, rootDir: string): string[] { - const files = getFiles(path.resolve(rootDir, relativePath)); - return files.map((file) => path.join('/', path.relative(rootDir, file))); -} +// function getFilePaths(relativePath: string, rootDir: string): string[] { +// const files = getFiles(path.resolve(rootDir, relativePath)); +// return files.map((file) => path.join('/', path.relative(rootDir, file))); +// } export function registerSsr(app: FastifyInstance): void { app.register(fastifyStatic, { @@ -59,30 +58,35 @@ export function registerSsr(app: FastifyInstance): void { , ); - const rootDir = path.resolve(__dirname, '../../../'); - const imagePaths = [ - getFilePaths('public/images', rootDir), - getFilePaths('public/animations', rootDir), - getFilePaths('public/logos', rootDir), - ].flat(); + // const rootDir = path.resolve(__dirname, '../../../'); + // const imagePaths = [ + // getFilePaths('public/images', rootDir), + // getFilePaths('public/animations', rootDir), + // getFilePaths('public/logos', rootDir), + // ].flat(); + + // ↓ からを消した + // ${/* imagePaths.map((imagePath) => ``).join('\n') */ ''} + reply.type('text/html').send(/* html */ ` - + - - ${imagePaths.map((imagePath) => ``).join('\n')} + - + +
+ + - `); }); } diff --git a/workspaces/server/src/streams.tsx b/workspaces/server/src/streams.tsx index 0c45fcf6d..5b1c7f32f 100644 --- a/workspaces/server/src/streams.tsx +++ b/workspaces/server/src/streams.tsx @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,6 +24,74 @@ export function registerStreams(app: FastifyInstance): void { root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../streams'), }); + // 静的サムネイルディレクトリを提供 + app.register(fastifyStatic, { + prefix: '/thumbnails/', + root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../static/thumbnails'), + decorateReply: false, // 既に登録済みのため + }); + + // 静的なサムネイル画像のフォールバック + app.get<{ + Params: { episodeId: string }; + }>('/thumbnails/episode/:episodeId/preview.jpg', async (req, reply) => { + const episodeId = req.params.episodeId; + console.log(`Thumbnail requested for episode: ${episodeId}`); + + // 1. ストリームIDに基づくデフォルトサムネイルを探す(優先的に) + const database = getDatabase(); + const episode = await database.query.episode.findFirst({ + where(episode, { eq }) { + return eq(episode.id, episodeId); + }, + with: { + stream: true, + }, + }); + + if (episode) { + const streamId = episode.stream.id; + console.log(`Found stream ID: ${streamId} for episode: ${episodeId}`); + + // ストリームIDに基づくサムネイルを探す + const streamThumbnailPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + `../static/thumbnails/${streamId}.jpg` + ); + + if (fs.existsSync(streamThumbnailPath)) { + console.log(`Found stream thumbnail at: ${streamThumbnailPath}`); + return reply.sendFile(`${streamId}.jpg`, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../static/thumbnails')); + } + } + + // 2. 事前生成されたエピソード固有のサムネイルを探す + const staticThumbnailPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + `../static/thumbnails/${episodeId}.jpg` + ); + + // 事前生成されたサムネイルが存在する場合はそれを返す + if (fs.existsSync(staticThumbnailPath)) { + console.log(`Found static thumbnail at: ${staticThumbnailPath}`); + return reply.sendFile(`${episodeId}.jpg`, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../static/thumbnails')); + } + + // 3. 汎用的なデフォルトサムネイルを返す + const defaultThumbnailPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../static/thumbnails/default.jpg' + ); + + if (fs.existsSync(defaultThumbnailPath)) { + console.log(`Using default thumbnail at: ${defaultThumbnailPath}`); + return reply.sendFile('default.jpg', path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../static/thumbnails')); + } + + // 4. デフォルトサムネイルも存在しない場合は404を返す + return reply.code(404).send({ error: 'Thumbnail not found' }); + }); + app.get<{ Params: { episodeId: string }; }>('/streams/episode/:episodeId/playlist.m3u8', async (req, reply) => { @@ -122,7 +191,7 @@ export function registerStreams(app: FastifyInstance): void { `ID="arema-${sequence}"`, `START-DATE="${sequenceStartAt.toISOString()}"`, `DURATION=2.0`, - `X-AREMA-INTERNAL="${randomBytes(3 * 1024 * 1024).toString('base64')}"`, + `X-AREMA-INTERNAL="${randomBytes(16).toString('base64')}"`, ].join(',')} `, ); diff --git a/workspaces/server/static/thumbnails/caminandes2.jpg b/workspaces/server/static/thumbnails/caminandes2.jpg new file mode 100644 index 000000000..096462258 Binary files /dev/null and b/workspaces/server/static/thumbnails/caminandes2.jpg differ diff --git a/workspaces/server/static/thumbnails/dailydweebs.jpg b/workspaces/server/static/thumbnails/dailydweebs.jpg new file mode 100644 index 000000000..ff99fbf18 Binary files /dev/null and b/workspaces/server/static/thumbnails/dailydweebs.jpg differ diff --git a/workspaces/server/static/thumbnails/glasshalf.jpg b/workspaces/server/static/thumbnails/glasshalf.jpg new file mode 100644 index 000000000..ce7179e5e Binary files /dev/null and b/workspaces/server/static/thumbnails/glasshalf.jpg differ diff --git a/workspaces/server/static/thumbnails/wing-it.jpg b/workspaces/server/static/thumbnails/wing-it.jpg new file mode 100644 index 000000000..042c275b2 Binary files /dev/null and b/workspaces/server/static/thumbnails/wing-it.jpg differ diff --git a/workspaces/server/thumbnails/caminandes2/preview.jpg b/workspaces/server/thumbnails/caminandes2/preview.jpg new file mode 100644 index 000000000..096462258 Binary files /dev/null and b/workspaces/server/thumbnails/caminandes2/preview.jpg differ diff --git a/workspaces/server/thumbnails/dailydweebs/preview.jpg b/workspaces/server/thumbnails/dailydweebs/preview.jpg new file mode 100644 index 000000000..ff99fbf18 Binary files /dev/null and b/workspaces/server/thumbnails/dailydweebs/preview.jpg differ diff --git a/workspaces/server/thumbnails/glasshalf/preview.jpg b/workspaces/server/thumbnails/glasshalf/preview.jpg new file mode 100644 index 000000000..ce7179e5e Binary files /dev/null and b/workspaces/server/thumbnails/glasshalf/preview.jpg differ diff --git a/workspaces/server/thumbnails/wing-it/preview.jpg b/workspaces/server/thumbnails/wing-it/preview.jpg new file mode 100644 index 000000000..042c275b2 Binary files /dev/null and b/workspaces/server/thumbnails/wing-it/preview.jpg differ diff --git a/workspaces/server/tools/create_default_thumbnails.sh b/workspaces/server/tools/create_default_thumbnails.sh new file mode 100755 index 000000000..2915dced6 --- /dev/null +++ b/workspaces/server/tools/create_default_thumbnails.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# デフォルトのサムネイル画像を作成するスクリプト + +# 必要なディレクトリを作成 +mkdir -p workspaces/server/static/thumbnails + +# デフォルトのサムネイル画像を作成 +# ImageMagickを使用する場合 +if command -v convert &> /dev/null; then + echo "ImageMagickを使用してデフォルトのサムネイル画像を作成します" + convert -size 1600x90 xc:#333333 workspaces/server/static/thumbnails/default.jpg +# FFmpegを使用する場合 +elif command -v ffmpeg &> /dev/null; then + echo "FFmpegを使用してデフォルトのサムネイル画像を作成します" + ffmpeg -f lavfi -i color=c=gray:s=1600x90 -frames:v 1 workspaces/server/static/thumbnails/default.jpg -y +else + echo "ImageMagickまたはFFmpegがインストールされていません" + exit 1 +fi + +# ストリームごとのサムネイル画像を作成 +# 例: caminandes2, dailydweebs, glasshalf, wing-it +for stream_id in caminandes2 dailydweebs glasshalf wing-it; do + # デフォルトのサムネイル画像をコピー + cp workspaces/server/static/thumbnails/default.jpg workspaces/server/static/thumbnails/${stream_id}.jpg + + echo "ストリーム ${stream_id} のサムネイル画像を作成しました" +done + +echo "デフォルトのサムネイル画像の作成が完了しました" \ No newline at end of file diff --git a/workspaces/server/tools/generate_thumbnails.ts b/workspaces/server/tools/generate_thumbnails.ts new file mode 100644 index 000000000..a10925fea --- /dev/null +++ b/workspaces/server/tools/generate_thumbnails.ts @@ -0,0 +1,213 @@ +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import util from 'util'; + +import { getDatabase } from '../src/drizzle/database'; + +const execPromise = util.promisify(exec); +// サムネイルを保存するディレクトリ +const THUMBNAILS_DIR = path.resolve(__dirname, '../thumbnails'); +const STATIC_THUMBNAILS_DIR = path.resolve(__dirname, '../static/thumbnails'); + +// サムネイルディレクトリが存在しない場合は作成 +if (!fs.existsSync(THUMBNAILS_DIR)) { + fs.mkdirSync(THUMBNAILS_DIR, { recursive: true }); +} + +// 静的サムネイルディレクトリが存在しない場合は作成 +if (!fs.existsSync(STATIC_THUMBNAILS_DIR)) { + fs.mkdirSync(STATIC_THUMBNAILS_DIR, { recursive: true }); +} + +// デフォルトのサムネイル画像を作成 +async function createDefaultThumbnail(): Promise { + const defaultThumbnailPath = path.join(STATIC_THUMBNAILS_DIR, 'default.jpg'); + + if (!fs.existsSync(defaultThumbnailPath)) { + try { + // FFmpegを使用してデフォルトのサムネイル画像を作成 + const command = `ffmpeg -f lavfi -i color=c=gray:s=1600x90 -frames:v 1 "${defaultThumbnailPath}" -y`; + await execPromise(command); + console.log('デフォルトのサムネイル画像を作成しました'); + } catch (error) { + console.error('デフォルトのサムネイル画像の作成に失敗しました:', error); + } + } +} + +async function generateThumbnailForEpisode(stream :string ): Promise { + // const database = getDatabase(); + + // // エピソード情報を取得 + // const episode = await database.query.episode.findFirst({ + // where(episode, { eq }) { + // return eq(episode.id, stream); + // }, + // with: { + // stream: true, + // }, + // }); + + // if (!episode) { + // console.error(`Episode ${stream} not found`); + // return; + // } + + // 両方のディレクトリパスを設定 + const episodeDir = path.join(THUMBNAILS_DIR, stream); + const staticThumbnailPath = path.join(STATIC_THUMBNAILS_DIR, `${stream}.jpg`); + + // エピソード用のディレクトリを作成 + if (!fs.existsSync(episodeDir)) { + fs.mkdirSync(episodeDir, { recursive: true }); + } + + // プレイリストのパス + const streamDir = path.resolve(__dirname, `../streams/${stream}`); + const tsFiles = fs.readdirSync(streamDir) + .filter(file => file.endsWith('.ts')) + .map(file => path.join(streamDir, file)) + .sort(); + + if (tsFiles.length === 0) { + console.error(`No TS files found for stream ${stream}`); + return; + } + + // サムネイル画像のパス + const thumbnailPath = path.join(episodeDir, 'preview.jpg'); + + // 一時的な個別サムネイルを生成 + const thumbnailPromises = []; + const thumbnailPaths = []; + + try { + // 動画の長さに応じてサムネイルの数を決定(最大20個) + const numThumbnails = 50 //Math.min(20, Math.max(10, Math.ceil(tsFiles.length / 3))); + console.log(`Generating ${numThumbnails} thumbnails for episode ${stream} (${tsFiles.length} TS files)`); + + for (let i = 0; i < numThumbnails; i++) { + const index = Math.floor(i * tsFiles.length / numThumbnails); + const tsFile = tsFiles[index]; + const outputPath = path.join(episodeDir, `thumbnail-${i + 1}.jpg`); + thumbnailPaths.push(outputPath); + + const command = `ffmpeg -i "${tsFile}" -vf "select=eq(n\\,0),scale=160:90" -vframes 1 "${outputPath}" -y`; + thumbnailPromises.push(execPromise(command)); + } + + await Promise.all(thumbnailPromises); + + // 画像を横に並べるための一時ファイルを作成 + const montageCommand = `montage ${thumbnailPaths.join(' ')} -tile ${thumbnailPaths.length}x1 -geometry 160x90+0+0 "${thumbnailPath}"`; + + try { + // ImageMagickのmontageコマンドを試す + await execPromise(montageCommand); + + // 成功したら静的ディレクトリにもコピー + fs.copyFileSync(thumbnailPath, staticThumbnailPath); + } catch (montageError) { + console.warn('montage command failed, falling back to ffmpeg hstack:', montageError); + + // montageが失敗した場合、ffmpegのhstackフィルターを使用 + const hstackCommand = `ffmpeg -y ${thumbnailPaths.map(p => `-i "${p}"`).join(' ')} -filter_complex "hstack=inputs=${numThumbnails}" "${thumbnailPath}"`; + await execPromise(hstackCommand); + + // 成功したら静的ディレクトリにもコピー + fs.copyFileSync(thumbnailPath, staticThumbnailPath); + } + + console.log(`Generated thumbnail for episode ${stream} in both directories`); + } catch (error) { + console.error(`Error generating thumbnail for episode ${stream}:`, error); + + // エラーが発生した場合でも、個別のサムネイルを1つだけ使用する + try { + const firstThumbnail = thumbnailPaths[0]; + if (thumbnailPaths.length > 0 && firstThumbnail && fs.existsSync(firstThumbnail)) { + fs.copyFileSync(firstThumbnail, thumbnailPath); + // 静的ディレクトリにもコピー + fs.copyFileSync(firstThumbnail, staticThumbnailPath); + console.log(`Fallback: Using single thumbnail for episode ${stream} in both directories`); + } + } catch (fallbackError) { + console.error(`Fallback also failed for episode ${stream}:`, fallbackError); + } + + throw error; + } finally { + // 個別のサムネイルを削除 + for (const tmpPath of thumbnailPaths) { + if (fs.existsSync(tmpPath)) { + try { + fs.unlinkSync(tmpPath); + } catch (e) { + console.warn(`Failed to delete temporary file ${tmpPath}:`, e); + } + } + } + } +} + +async function generateAllThumbnails(): Promise { + const database = getDatabase(); + + // すべてのエピソードを取得 + const episodes = await database.query.episode.findMany({ + with: { + stream: true, + }, + }); + + console.log(`Generating thumbnails for ${episodes.length} episodes...`); + + // 各エピソードのサムネイルを生成 + for (const episode of episodes) { + try { + await generateThumbnailForEpisode(episode.id); + } catch (error) { + console.error(`Error processing episode ${episode.id}:`, error); + } + } + + console.log('Thumbnail generation complete'); +} + +// メイン処理 +async function main() { + try { + // まずデフォルトのサムネイル画像を作成 + await createDefaultThumbnail(); + + // ストリームごとのデフォルトサムネイルを作成 + const streamIds = ['caminandes2', 'dailydweebs', 'glasshalf', 'wing-it']; + for (const streamId of streamIds) { + const streamThumbnailPath = path.join(STATIC_THUMBNAILS_DIR, `${streamId}.jpg`); + if (!fs.existsSync(streamThumbnailPath)) { + try { + // デフォルトのサムネイル画像をコピー + fs.copyFileSync(path.join(STATIC_THUMBNAILS_DIR, 'default.jpg'), streamThumbnailPath); + console.log(`ストリーム ${streamId} のサムネイル画像を作成しました`); + } catch (error) { + console.error(`ストリーム ${streamId} のサムネイル画像の作成に失敗しました:`, error); + } + } + } + + // すべてのエピソードのサムネイルを生成 + await generateAllThumbnails(); + } catch (error) { + console.error('Error generating thumbnails:', error); + process.exit(1); + } +} + +// このファイルが直接実行された場合のみmain()を実行 +if (require.main === module) { + main(); +} + +export default main; +export { generateThumbnailForEpisode }; diff --git a/workspaces/server/tools/seed.ts b/workspaces/server/tools/seed.ts index 611eba167..288c0187b 100644 --- a/workspaces/server/tools/seed.ts +++ b/workspaces/server/tools/seed.ts @@ -2,11 +2,13 @@ import { en, Faker, ja } from '@faker-js/faker'; import { createClient } from '@libsql/client'; import * as schema from '@wsh-2025/schema/src/database/schema'; import { drizzle } from 'drizzle-orm/libsql'; +import { eq } from 'drizzle-orm'; import { reset } from 'drizzle-seed'; import { DateTime } from 'luxon'; import { fetchAnimeList } from '@wsh-2025/server/tools/fetch_anime_list'; import { fetchLoremIpsumWordList } from '@wsh-2025/server/tools/fetch_lorem_ipsum_word_list'; +import generateThumbnails, { generateThumbnailForEpisode } from '@wsh-2025/server/tools/generate_thumbnails'; import * as bcrypt from 'bcrypt'; import path from 'node:path'; import { readdirSync } from 'node:fs'; @@ -131,7 +133,7 @@ async function main() { const data: (typeof schema.series.$inferInsert)[] = Array.from({ length: 30 }, () => ({ description: faker.lorem.paragraph({ max: 200, min: 100 }).replace(/\s/g, '').replace(/\./g, '。'), id: faker.string.uuid(), - thumbnailUrl: `${faker.helpers.arrayElement(imagePaths)}?version=${faker.string.nanoid()}`, + thumbnailUrl: `${faker.helpers.arrayElement(imagePaths)}`, title: faker.helpers.arrayElement(seriesTitleList), })); const result = await database.insert(schema.series).values(data).returning(); @@ -150,7 +152,7 @@ async function main() { order: idx + 1, seriesId: series.id, streamId: faker.helpers.arrayElement(streamList).id, - thumbnailUrl: `${faker.helpers.arrayElement(imagePaths)}?version=${faker.string.nanoid()}`, + thumbnailUrl: `${faker.helpers.arrayElement(imagePaths)}`, title: `第${String(idx + 1)}話 ${faker.helpers.arrayElement(episodeTitleList)}`, premium: idx % 5 === 0, }), @@ -159,6 +161,22 @@ async function main() { episodeList.push(...result); } + // エピソードごとにサムネイルを生成し、サムネイルURLを更新 + // console.log('Generating thumbnails for episodes...'); + + // const streams = ['caminandes2', 'dailydweebs', 'glasshalf', 'wing-it']; + // for (const stream of streams) { + // try { + // await generateThumbnailForEpisode(stream); + + // // このサムネイルとthumbnailUrlは別物 + + // console.log(`Generated and updated thumbnail for episode ${stream}`); + // } catch (error) { + // console.error(`Error generating thumbnail for episode ${stream}:`, error); + // } + // } + // Create programs console.log('Creating programs...'); const programList: (typeof schema.program.$inferInsert)[] = []; @@ -183,7 +201,7 @@ async function main() { episodeId: episode.id, id: faker.string.uuid(), startAt: new Date(startAt).toISOString(), - thumbnailUrl: `${faker.helpers.arrayElement(imagePaths)}?version=${faker.string.nanoid()}`, + thumbnailUrl: `${faker.helpers.arrayElement(imagePaths)}`, title: `${series?.title ?? ''} ${episode.title}`, }; programList.push(program); @@ -303,6 +321,20 @@ async function main() { } finally { database.$client.close(); } + + // デフォルトのサムネイル画像を作成 + // console.log('Creating default thumbnails...'); + // try { + // // create_default_thumbnails.shを実行 + // const { exec } = require('child_process'); + // const { promisify } = require('util'); + // const execPromise = promisify(exec); + + // await execPromise('bash ./tools/create_default_thumbnails.sh'); + // console.log('Default thumbnails created successfully.'); + // } catch (error) { + // console.error('Error creating default thumbnails:', error); + // } } main().catch((error: unknown) => { diff --git a/workspaces/server/workspaces/server/static/thumbnails/caminandes2.jpg b/workspaces/server/workspaces/server/static/thumbnails/caminandes2.jpg new file mode 100644 index 000000000..17ad76b16 Binary files /dev/null and b/workspaces/server/workspaces/server/static/thumbnails/caminandes2.jpg differ diff --git a/workspaces/server/workspaces/server/static/thumbnails/dailydweebs.jpg b/workspaces/server/workspaces/server/static/thumbnails/dailydweebs.jpg new file mode 100644 index 000000000..17ad76b16 Binary files /dev/null and b/workspaces/server/workspaces/server/static/thumbnails/dailydweebs.jpg differ diff --git a/workspaces/server/workspaces/server/static/thumbnails/default.jpg b/workspaces/server/workspaces/server/static/thumbnails/default.jpg new file mode 100644 index 000000000..17ad76b16 Binary files /dev/null and b/workspaces/server/workspaces/server/static/thumbnails/default.jpg differ diff --git a/workspaces/server/workspaces/server/static/thumbnails/glasshalf.jpg b/workspaces/server/workspaces/server/static/thumbnails/glasshalf.jpg new file mode 100644 index 000000000..17ad76b16 Binary files /dev/null and b/workspaces/server/workspaces/server/static/thumbnails/glasshalf.jpg differ diff --git a/workspaces/server/workspaces/server/static/thumbnails/wing-it.jpg b/workspaces/server/workspaces/server/static/thumbnails/wing-it.jpg new file mode 100644 index 000000000..17ad76b16 Binary files /dev/null and b/workspaces/server/workspaces/server/static/thumbnails/wing-it.jpg differ