From 5389ba2b355111d47da796bc3362ba08a6de7479 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Mon, 8 Apr 2024 21:03:55 +0800 Subject: [PATCH 01/10] updated ffmpeg url from unpkg to jsdeilvr --- src/ffmpeg/core.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ffmpeg/core.ts b/src/ffmpeg/core.ts index 7bd6d5be..ecc31864 100644 --- a/src/ffmpeg/core.ts +++ b/src/ffmpeg/core.ts @@ -4,10 +4,14 @@ import type { FFmpeg } from "@ffmpeg/ffmpeg"; import { toBlobURL } from "@ffmpeg/util"; import ffmpegWorkerJs from 'url:assets/ffmpeg/worker.js'; +const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm" + export class SingleThread implements FFMpegCore { async load(ffmpeg: FFmpeg): Promise { return ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "application/javascript"), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), classWorkerURL: await toBlobURL(ffmpegWorkerJs, 'text/javascript') }) } From 41a3892658acc46231c6bb4d9f3c90e2f03f70ae Mon Sep 17 00:00:00 2001 From: eric2788 Date: Mon, 8 Apr 2024 22:26:34 +0800 Subject: [PATCH 02/10] updated tutorial link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9185968c..0e6a2ed9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ 2. 点击扩展图标进入设定页面,并根据你的偏好进入设定。完成后,然后按下保存设定。 3. 进入B站任一直播间即可开始使用。 -详情可参阅 [使用指南](https://cdn.jsdelivr.net/gh/eric2788/bilibili-vup-stream-enhancer@web/tutorials/index.md) +详情可参阅 [使用指南](https://eric2788.github.io/bilibili-vup-stream-enhancer/tutorials) ## ➵ 贡献 From 8992d366dd5a1cfd1b7c5e5962b0b7c3aabd4784 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Mon, 8 Apr 2024 23:11:56 +0800 Subject: [PATCH 03/10] added tutorial button for feature settings --- src/options/features/jimaku/index.tsx | 7 +++---- src/options/fragments/features.tsx | 14 +++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/options/features/jimaku/index.tsx b/src/options/features/jimaku/index.tsx index 452f711b..fc3175c9 100644 --- a/src/options/features/jimaku/index.tsx +++ b/src/options/features/jimaku/index.tsx @@ -1,16 +1,15 @@ -import { Collapse, List } from "@material-tailwind/react" +import { List } from "@material-tailwind/react" import { Fragment, type ChangeEvent } from "react" import { asStateProxy, useBinding, type StateProxy } from "~hooks/binding" +import Expander from "~options/components/Expander" import ExperienmentFeatureIcon from "~options/components/ExperientmentFeatureIcon" import SwitchListItem from "~options/components/SwitchListItem" +import type { PickKeys } from "~types/common" import type { FeatureSettingsDefinition } from ".." import ButtonFragment, { buttonDefaultSettings, type ButtonSchema } from "./components/ButtonFragment" import DanmakuZone, { danmakuDefaultSettings, type DanmakuSchema } from "./components/DanmakuFragment" import JimakuZone, { jimakuDefaultSettings, type JimakuSchema } from "./components/JimakuFragment" import ListingFragment, { listingDefaultSettings, type ListingSchema } from "./components/ListingFragment" -import { useToggle } from "@react-hooks-library/core" -import Expander from "~options/components/Expander" -import type { PickKeys } from "~types/common" export const title: string = '同传弹幕过滤' diff --git a/src/options/fragments/features.tsx b/src/options/fragments/features.tsx index c98c6626..922943b6 100644 --- a/src/options/fragments/features.tsx +++ b/src/options/fragments/features.tsx @@ -3,7 +3,7 @@ import { ensureIsVtuber, type StreamInfo } from '~api/bilibili'; import SwitchListItem from '~options/components/SwitchListItem'; import { sendMessager } from '~utils/messaging'; -import { Collapse, IconButton, List, Switch, Typography } from '@material-tailwind/react'; +import { Button, Collapse, IconButton, List, Switch, Typography } from '@material-tailwind/react'; import { toast } from 'sonner/dist'; import type { TableType } from "~database"; @@ -29,7 +29,7 @@ export type SettingSchema = { export const defaultSettings: Readonly = { - enabledFeatures: [ 'jimaku' ], + enabledFeatures: ['jimaku'], enabledRecording: [], common: { enabledPip: false, @@ -106,6 +106,14 @@ function FeatureSettings({ state, useHandler }: StateProxy): JSX. return (
+
+ +
@@ -138,7 +146,7 @@ function FeatureSettings({ state, useHandler }: StateProxy): JSX. type F = typeof f const setting = settings[f] as FeatureSettings[F] const Component = setting.default as React.FC>> - console.info(state) + console.debug(state) const props = asStateProxy(useBinding(state[f], true)) return ( From 7463c8a6c703c1f5f6562b3cf80c41b182720535 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Tue, 9 Apr 2024 16:26:51 +0800 Subject: [PATCH 04/10] added backBufferLength to 30 for reduce memory usage --- src/players/hls.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/players/hls.ts b/src/players/hls.ts index 7e6199f9..bcdac340 100644 --- a/src/players/hls.ts +++ b/src/players/hls.ts @@ -36,7 +36,8 @@ class HlsPlayer extends StreamPlayer { enableWorker: true, liveDurationInfinity: true, lowLatencyMode: true, - maxBufferLength: Infinity + maxBufferLength: Infinity, + backBufferLength: 30 }) return new Promise((res, rej) => { From 47afcf4263eac001b75d5124dbc03696fc7fd699 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Tue, 9 Apr 2024 17:59:14 +0800 Subject: [PATCH 05/10] isolated ProgressText into component file --- .../recorder/components/ProgressText.tsx | 46 ++++++++++++++++++ .../recorder/components/RecorderLayer.tsx | 48 ++----------------- 2 files changed, 50 insertions(+), 44 deletions(-) create mode 100644 src/features/recorder/components/ProgressText.tsx diff --git a/src/features/recorder/components/ProgressText.tsx b/src/features/recorder/components/ProgressText.tsx new file mode 100644 index 00000000..12b40d3a --- /dev/null +++ b/src/features/recorder/components/ProgressText.tsx @@ -0,0 +1,46 @@ +import type { ProgressEvent } from "@ffmpeg/ffmpeg/dist/esm/types" +import { Spinner, Progress } from "@material-tailwind/react" +import { useState } from "react" +import TailwindScope from "~components/TailwindScope" +import type { FFMpegHooks } from "~hooks/ffmpeg" +import { useAsyncEffect } from "~hooks/life-cycle" + + +function ProgressText({ ffmpeg }: { ffmpeg: Promise }) { + + const [progress, setProgress] = useState(null) + + useAsyncEffect( + async () => { + const ff = await ffmpeg + ff.onProgress(setProgress) + }, + async () => { }, + (err) => { + console.error('unexpected: ', err) + }, + [ffmpeg]) + + if (!progress) { + return `编译视频中...` + } + + return ( + +
+
+
+ +
+
+ {`编译视频中... (${Math.round(progress.progress * 10000) / 100}%)`} +
+
+ +
+
+ ) + +} + +export default ProgressText \ No newline at end of file diff --git a/src/features/recorder/components/RecorderLayer.tsx b/src/features/recorder/components/RecorderLayer.tsx index 8d98b4f6..ea1e71d8 100644 --- a/src/features/recorder/components/RecorderLayer.tsx +++ b/src/features/recorder/components/RecorderLayer.tsx @@ -1,66 +1,26 @@ -import type { ProgressEvent } from "@ffmpeg/ffmpeg/dist/esm/types" -import { Progress, Spinner } from "@material-tailwind/react" import { useKeyDown } from "@react-hooks-library/core" -import { useCallback, useContext, useRef, useState } from "react" +import { useCallback, useContext, useRef } from "react" import { createPortal } from "react-dom" import { toast } from "sonner/dist" import type { StreamUrls } from "~background/messages/get-stream-urls" -import TailwindScope from "~components/TailwindScope" import ContentContext from "~contexts/ContentContexts" import RecorderFeatureContext from "~contexts/RecorderFeatureContext" -import { FFMpegHooks, useFFMpeg } from "~hooks/ffmpeg" +import { useFFMpeg } from "~hooks/ffmpeg" import { useAsyncEffect } from "~hooks/life-cycle" import { useShardSender } from "~hooks/stream" import { Recorder } from "~types/media" +import { screenshotFromVideo } from "~utils/binary" import { downloadBlob } from "~utils/file" import { sendMessager } from "~utils/messaging" import { randomString } from '~utils/misc' import createRecorder from "../recorders" +import ProgressText from "./ProgressText" import RecorderButton from "./RecorderButton" -import { screenshotFromVideo } from "~utils/binary" export type RecorderLayerProps = { urls: StreamUrls } - -function ProgressText({ ffmpeg }: { ffmpeg: Promise }) { - - const [progress, setProgress] = useState(null) - - useAsyncEffect( - async () => { - const ff = await ffmpeg - ff.onProgress(setProgress) - }, - async () => { }, - (err) => { - console.error('unexpected: ', err) - }, - [ffmpeg]) - - if (!progress) { - return `编译视频中...` - } - - return ( - -
-
-
- -
-
- {`编译视频中... (${Math.round(progress.progress * 10000) / 100}%)`} -
-
- -
-
- ) - -} - function RecorderLayer(props: RecorderLayerProps): JSX.Element { const { urls } = props From 09f9d14fcd9aaff02b6a1dfaeb6dd98f1a2b7b4f Mon Sep 17 00:00:00 2001 From: eric2788 Date: Tue, 9 Apr 2024 18:03:32 +0800 Subject: [PATCH 06/10] Reshaped type definitions and interfaces to .ts if they not belong to declare module --- src/types/bilibili/api/{index.d.ts => index.ts} | 0 src/types/bilibili/api/{room-info.d.ts => room-info.ts} | 0 src/types/bilibili/api/{room-init.d.ts => room-init.ts} | 0 .../api/{spec-area-rank.d.ts => spec-area-rank.ts} | 0 src/types/bilibili/api/{stream-url.d.ts => stream-url.ts} | 0 .../api/{superchat-list.d.ts => superchat-list.ts} | 0 .../bilibili/api/{wbi-acc-info.d.ts => wbi-acc-info.ts} | 0 .../api/{web-interface-nav.d.ts => web-interface-nav.ts} | 0 src/types/bilibili/{index.d.ts => index.ts} | 0 src/types/bilibili/live/{danmu_msg.d.ts => danmu_msg.ts} | 0 src/types/bilibili/live/{index.d.ts => index.ts} | 8 ++++---- .../live/{interact_word.d.ts => interact_word.ts} | 0 .../{super_chat_message.d.ts => super_chat_message.ts} | 0 src/types/bilibili/{vtb-moe.d.ts => vtb-moe.ts} | 0 src/types/common/{index.d.ts => index.ts} | 0 src/types/common/{leaf.d.ts => leaf.ts} | 0 src/types/common/{react.d.ts => react.ts} | 0 src/types/common/{schema.d.ts => schema.ts} | 2 +- src/types/github/{index.d.ts => index.ts} | 0 src/types/github/{release.d.ts => release.ts} | 0 20 files changed, 5 insertions(+), 5 deletions(-) rename src/types/bilibili/api/{index.d.ts => index.ts} (100%) rename src/types/bilibili/api/{room-info.d.ts => room-info.ts} (100%) rename src/types/bilibili/api/{room-init.d.ts => room-init.ts} (100%) rename src/types/bilibili/api/{spec-area-rank.d.ts => spec-area-rank.ts} (100%) rename src/types/bilibili/api/{stream-url.d.ts => stream-url.ts} (100%) rename src/types/bilibili/api/{superchat-list.d.ts => superchat-list.ts} (100%) rename src/types/bilibili/api/{wbi-acc-info.d.ts => wbi-acc-info.ts} (100%) rename src/types/bilibili/api/{web-interface-nav.d.ts => web-interface-nav.ts} (100%) rename src/types/bilibili/{index.d.ts => index.ts} (100%) rename src/types/bilibili/live/{danmu_msg.d.ts => danmu_msg.ts} (100%) rename src/types/bilibili/live/{index.d.ts => index.ts} (64%) rename src/types/bilibili/live/{interact_word.d.ts => interact_word.ts} (100%) rename src/types/bilibili/live/{super_chat_message.d.ts => super_chat_message.ts} (100%) rename src/types/bilibili/{vtb-moe.d.ts => vtb-moe.ts} (100%) rename src/types/common/{index.d.ts => index.ts} (100%) rename src/types/common/{leaf.d.ts => leaf.ts} (100%) rename src/types/common/{react.d.ts => react.ts} (100%) rename src/types/common/{schema.d.ts => schema.ts} (91%) rename src/types/github/{index.d.ts => index.ts} (100%) rename src/types/github/{release.d.ts => release.ts} (100%) diff --git a/src/types/bilibili/api/index.d.ts b/src/types/bilibili/api/index.ts similarity index 100% rename from src/types/bilibili/api/index.d.ts rename to src/types/bilibili/api/index.ts diff --git a/src/types/bilibili/api/room-info.d.ts b/src/types/bilibili/api/room-info.ts similarity index 100% rename from src/types/bilibili/api/room-info.d.ts rename to src/types/bilibili/api/room-info.ts diff --git a/src/types/bilibili/api/room-init.d.ts b/src/types/bilibili/api/room-init.ts similarity index 100% rename from src/types/bilibili/api/room-init.d.ts rename to src/types/bilibili/api/room-init.ts diff --git a/src/types/bilibili/api/spec-area-rank.d.ts b/src/types/bilibili/api/spec-area-rank.ts similarity index 100% rename from src/types/bilibili/api/spec-area-rank.d.ts rename to src/types/bilibili/api/spec-area-rank.ts diff --git a/src/types/bilibili/api/stream-url.d.ts b/src/types/bilibili/api/stream-url.ts similarity index 100% rename from src/types/bilibili/api/stream-url.d.ts rename to src/types/bilibili/api/stream-url.ts diff --git a/src/types/bilibili/api/superchat-list.d.ts b/src/types/bilibili/api/superchat-list.ts similarity index 100% rename from src/types/bilibili/api/superchat-list.d.ts rename to src/types/bilibili/api/superchat-list.ts diff --git a/src/types/bilibili/api/wbi-acc-info.d.ts b/src/types/bilibili/api/wbi-acc-info.ts similarity index 100% rename from src/types/bilibili/api/wbi-acc-info.d.ts rename to src/types/bilibili/api/wbi-acc-info.ts diff --git a/src/types/bilibili/api/web-interface-nav.d.ts b/src/types/bilibili/api/web-interface-nav.ts similarity index 100% rename from src/types/bilibili/api/web-interface-nav.d.ts rename to src/types/bilibili/api/web-interface-nav.ts diff --git a/src/types/bilibili/index.d.ts b/src/types/bilibili/index.ts similarity index 100% rename from src/types/bilibili/index.d.ts rename to src/types/bilibili/index.ts diff --git a/src/types/bilibili/live/danmu_msg.d.ts b/src/types/bilibili/live/danmu_msg.ts similarity index 100% rename from src/types/bilibili/live/danmu_msg.d.ts rename to src/types/bilibili/live/danmu_msg.ts diff --git a/src/types/bilibili/live/index.d.ts b/src/types/bilibili/live/index.ts similarity index 64% rename from src/types/bilibili/live/index.d.ts rename to src/types/bilibili/live/index.ts index e01f291d..2f3b2079 100644 --- a/src/types/bilibili/live/index.d.ts +++ b/src/types/bilibili/live/index.ts @@ -1,6 +1,6 @@ -import { DanmuMsg } from './danmu_msg' -import { InteractWord } from './interact_word' -import { SuperChatMessage } from './super_chat_message' +import type { DanmuMsg } from './danmu_msg' +import type { InteractWord } from './interact_word' +import type { SuperChatMessage } from './super_chat_message' export type BLiveData = { 'DANMU_MSG': DanmuMsg, @@ -13,7 +13,7 @@ export type BLiveType = keyof BLiveData export type BLiveDataWild = T extends BLiveType ? BLiveData[T] : any -export { +export type { DanmuMsg, InteractWord, SuperChatMessage diff --git a/src/types/bilibili/live/interact_word.d.ts b/src/types/bilibili/live/interact_word.ts similarity index 100% rename from src/types/bilibili/live/interact_word.d.ts rename to src/types/bilibili/live/interact_word.ts diff --git a/src/types/bilibili/live/super_chat_message.d.ts b/src/types/bilibili/live/super_chat_message.ts similarity index 100% rename from src/types/bilibili/live/super_chat_message.d.ts rename to src/types/bilibili/live/super_chat_message.ts diff --git a/src/types/bilibili/vtb-moe.d.ts b/src/types/bilibili/vtb-moe.ts similarity index 100% rename from src/types/bilibili/vtb-moe.d.ts rename to src/types/bilibili/vtb-moe.ts diff --git a/src/types/common/index.d.ts b/src/types/common/index.ts similarity index 100% rename from src/types/common/index.d.ts rename to src/types/common/index.ts diff --git a/src/types/common/leaf.d.ts b/src/types/common/leaf.ts similarity index 100% rename from src/types/common/leaf.d.ts rename to src/types/common/leaf.ts diff --git a/src/types/common/react.d.ts b/src/types/common/react.ts similarity index 100% rename from src/types/common/react.d.ts rename to src/types/common/react.ts diff --git a/src/types/common/schema.d.ts b/src/types/common/schema.ts similarity index 91% rename from src/types/common/schema.d.ts rename to src/types/common/schema.ts index a17e3ee6..86f04a3b 100644 --- a/src/types/common/schema.d.ts +++ b/src/types/common/schema.ts @@ -18,7 +18,7 @@ export type Primitive = string | number | boolean | bigint | symbol | null | und export type KeyType = string | number | symbol -export type ConvertToPrimitive = T extends 'string' ? string : +export type ConvertToPrimitive = T extends 'string' ? string : T extends 'number' ? number : T extends 'boolean' ? boolean : T extends 'bigint' ? bigint : diff --git a/src/types/github/index.d.ts b/src/types/github/index.ts similarity index 100% rename from src/types/github/index.d.ts rename to src/types/github/index.ts diff --git a/src/types/github/release.d.ts b/src/types/github/release.ts similarity index 100% rename from src/types/github/release.d.ts rename to src/types/github/release.ts From ad57055f00b5c4d5d4b9d65ecd13f0d32059c5a5 Mon Sep 17 00:00:00 2001 From: Eric Lam Date: Sat, 13 Apr 2024 16:40:52 +0800 Subject: [PATCH 07/10] [PR feat] advanced updates for recorder feature (#82) * initialized capture recorder * changed selector selection to a function in tests * added test cases for capture recorder * added capture record type from recorder feature * fixed SourceBuffer full in HLS buff recorder * reshaped ffmpeg core/core-mt * optimized hls buffer and stream player * removed all @scoped --- package.json | 2 +- pnpm-lock.yaml | 23 +- src/background/functions/index.ts | 4 +- src/background/functions/p2pLivePlayer.ts | 13 + src/background/messages/get-stream-urls.ts | 6 +- src/database/tables/stream.d.ts | 4 +- .../recorder/components/ProgressText.tsx | 6 +- .../recorder/components/RecorderButton.tsx | 4 +- .../recorder/components/RecorderLayer.tsx | 44 +- src/features/recorder/recorders/buffer.ts | 62 +-- src/features/recorder/recorders/capture.ts | 149 ++++++ src/features/recorder/recorders/index.ts | 14 +- src/ffmpeg/core-mt.ts | 56 ++- src/ffmpeg/core.ts | 53 ++- src/ffmpeg/index.ts | 8 +- src/hooks/ffmpeg.ts | 21 +- src/options/features/recorder/index.tsx | 32 +- src/players/hls.ts | 72 ++- src/players/index.ts | 52 ++- src/tabs/encoder.tsx | 3 +- src/tabs/stream.tsx | 9 +- src/types/extends/global.d.ts | 4 + src/types/media/recorder.ts | 94 +++- tests/features/jimaku.spec.ts | 10 +- tests/features/recorder.spec.ts | 314 ++++++++++++- tests/integrations/player.spec.ts | 164 ------- tests/integrations/recorder.spec.ts | 425 ++++++++++++++++++ tests/modules/recorder.js | 3 + tests/units/buffer.spec.ts | 54 +++ tests/units/capture.spec.ts | 183 ++++++++ tests/utils/misc.ts | 18 + tests/utils/playwright.ts | 10 + 32 files changed, 1555 insertions(+), 361 deletions(-) create mode 100644 src/background/functions/p2pLivePlayer.ts create mode 100644 src/features/recorder/recorders/capture.ts delete mode 100644 tests/integrations/player.spec.ts create mode 100644 tests/integrations/recorder.spec.ts create mode 100644 tests/modules/recorder.js create mode 100644 tests/units/capture.spec.ts diff --git a/package.json b/package.json index b0386489..89520f0f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dexie": "^3.2.7", "dexie-react-hooks": "^1.1.7", "hash-wasm": "^4.11.0", - "hls.js": "^1.5.7", + "hls.js": "^1.5.8", "media-chrome": "^2.2.5", "mpegts.js": "^1.7.3", "n-danmaku": "^2.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abc9f547..39ddb3e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,8 @@ dependencies: specifier: ^4.11.0 version: 4.11.0 hls.js: - specifier: ^1.5.7 - version: 1.5.7 + specifier: ^1.5.8 + version: 1.5.8 media-chrome: specifier: ^2.2.5 version: 2.2.5 @@ -3574,13 +3574,12 @@ packages: dev: false optional: true - /bare-fs@2.2.2: - resolution: {integrity: sha512-X9IqgvyB0/VA5OZJyb5ZstoN62AzD7YxVGog13kkfYWYqJYcK0kcqLZ6TrmH5qr4/8//ejVcX4x/a0UvaogXmA==} + /bare-fs@2.2.3: + resolution: {integrity: sha512-amG72llr9pstfXOBOHve1WjiuKKAMnebcmMbPWDZ7BCevAoJLpugjuAPRsDINEyjT0a6tbaVx3DctkXIRbLuJw==} requiresBuild: true dependencies: bare-events: 2.2.1 - bare-os: 2.2.1 - bare-path: 2.1.0 + bare-path: 2.1.1 streamx: 2.16.1 dev: false optional: true @@ -3591,8 +3590,8 @@ packages: dev: false optional: true - /bare-path@2.1.0: - resolution: {integrity: sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==} + /bare-path@2.1.1: + resolution: {integrity: sha512-OHM+iwRDRMDBsSW7kl3dO62JyHdBKO3B25FB9vNQBPcGHMo4+eA8Yj41Lfbk3pS/seDY+siNge0LdRTulAau/A==} requiresBuild: true dependencies: bare-os: 2.2.1 @@ -4651,8 +4650,8 @@ packages: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} dev: false - /hls.js@1.5.7: - resolution: {integrity: sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==} + /hls.js@1.5.8: + resolution: {integrity: sha512-hJYMPfLhWO7/7+n4f9pn6bOheCGx0WgvVz7k3ouq3Pp1bja48NN+HeCQu3XCGYzqWQF/wo7Sk6dJAyWVJD8ECA==} dev: false /hoist-non-react-statics@3.3.2: @@ -6646,8 +6645,8 @@ packages: pump: 3.0.0 tar-stream: 3.1.7 optionalDependencies: - bare-fs: 2.2.2 - bare-path: 2.1.0 + bare-fs: 2.2.3 + bare-path: 2.1.1 dev: false /tar-stream@2.2.0: diff --git a/src/background/functions/index.ts b/src/background/functions/index.ts index c3c12db8..3e151f61 100644 --- a/src/background/functions/index.ts +++ b/src/background/functions/index.ts @@ -1,6 +1,7 @@ import boostWebSocketHook from './boostWebsocketHook' import getBLiveCachedData from './getBLiveCachedData' import getWindowVariable from './getWindowVariable' +import invokeLivePlayer from "./p2pLivePlayer" export interface InjectableFunction { name: T @@ -18,7 +19,8 @@ export type InjectableFunctionReturnType = Ret const functions = { getWindowVariable, getBLiveCachedData, - boostWebSocketHook + boostWebSocketHook, + invokeLivePlayer } diff --git a/src/background/functions/p2pLivePlayer.ts b/src/background/functions/p2pLivePlayer.ts new file mode 100644 index 00000000..21ff3639 --- /dev/null +++ b/src/background/functions/p2pLivePlayer.ts @@ -0,0 +1,13 @@ + + +function invokeLivePlayer(name: string, ...args: any[]): any { + const self = window as any + if (!self.$P2PLivePlayer) { + console.warn('P2PLivePlayer not found') + return undefined + } + return self.$P2PLivePlayer[name](...args) +} + + +export default invokeLivePlayer \ No newline at end of file diff --git a/src/background/messages/get-stream-urls.ts b/src/background/messages/get-stream-urls.ts index 40659c68..38028e18 100644 --- a/src/background/messages/get-stream-urls.ts +++ b/src/background/messages/get-stream-urls.ts @@ -8,14 +8,16 @@ export type RequestBody = { roomId: number | string } -export type StreamUrls = { +export type StreamUrl = { desc: string url: string type: PlayerType codec: string track: string quality: number -}[] +} + +export type StreamUrls = StreamUrl[] export type ResponseBody = { error?: string diff --git a/src/database/tables/stream.d.ts b/src/database/tables/stream.d.ts index 5f4150e8..ed026060 100644 --- a/src/database/tables/stream.d.ts +++ b/src/database/tables/stream.d.ts @@ -3,11 +3,11 @@ import { CommonSchema } from '~database' declare module '~database' { interface IndexedDatabase { - streams: Table + streams: Table } } -interface Streams extends CommonSchema { +interface Stream extends CommonSchema { content: Blob order: number } \ No newline at end of file diff --git a/src/features/recorder/components/ProgressText.tsx b/src/features/recorder/components/ProgressText.tsx index 12b40d3a..e3f050a0 100644 --- a/src/features/recorder/components/ProgressText.tsx +++ b/src/features/recorder/components/ProgressText.tsx @@ -25,6 +25,8 @@ function ProgressText({ ffmpeg }: { ffmpeg: Promise }) { return `编译视频中...` } + const progressValid = progress.progress > 0 && progress.progress <= 1 + return (
@@ -33,10 +35,10 @@ function ProgressText({ ffmpeg }: { ffmpeg: Promise }) {
- {`编译视频中... (${Math.round(progress.progress * 10000) / 100}%)`} + {`编译视频中... ${progressValid ? `(${Math.round(progress.progress * 10000) / 100}%)` : ''}`}
- + {progressValid && }
) diff --git a/src/features/recorder/components/RecorderButton.tsx b/src/features/recorder/components/RecorderButton.tsx index 841ab08c..11e5bf7c 100644 --- a/src/features/recorder/components/RecorderButton.tsx +++ b/src/features/recorder/components/RecorderButton.tsx @@ -36,7 +36,9 @@ function RecorderButton(props: RecorderButtonProps): JSX.Element { if (recording) { if (timer === duration * 60) return // if reached duration, stop increasing timer - setTimer(timer + 1) + if (recorder.current.ticking) { // only ticking recorder will increase timer + setTimer(timer + 1) + } } else { setTimer(0) } diff --git a/src/features/recorder/components/RecorderLayer.tsx b/src/features/recorder/components/RecorderLayer.tsx index ea1e71d8..f6d670d8 100644 --- a/src/features/recorder/components/RecorderLayer.tsx +++ b/src/features/recorder/components/RecorderLayer.tsx @@ -34,7 +34,8 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { mechanism, hiddenUI, outputType, - overflow + overflow, + autoSwitchQuality } = useContext(RecorderFeatureContext) const recorder = useRef() @@ -44,14 +45,19 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { useAsyncEffect( async () => { - recorder.current = createRecorder(info.room, urls, mechanism, { type: outputType, codec: 'avc' }) // ffmpeg.wasm is not supported hevc codec + // ffmpeg.wasm is not supported hevc codec + recorder.current = createRecorder(info.room, urls, mechanism, { + type: outputType, + codec: 'avc', + autoSwitchQuality + }) await recorder.current.flush() // clear old records if (!manual) { await recorder.current.start() } recorder.current.onerror = (err) => { console.error('recorder error: ', err) - toast.error('录制直播推流时出现错误: ' + err.message) + toast.error('录制直播时出现错误: ' + err.message) } }, async () => { @@ -79,13 +85,21 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { } if (!recorder.current.recording) { - if (manual) { - await recorder.current.start() - toast.info('开始录制...') - } else { - toast.warning('录制没有在加载时自动开始,请稍等片刻或刷新页面。') + try { + if (manual) { + await recorder.current.start() + toast.info('开始录制...') + } else { + toast.warning('录制没有在加载时自动开始,请稍等片刻或刷新页面。') + } + } catch (err: Error | any) { + console.error('unexpected error: ', err) + toast.error('未知错误: ' + err.message) + } finally { + return } - return + } else if (manual) { + recorder.current.stop() } const encoding = (async () => { @@ -145,13 +159,14 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { } if (manual) { - recorder.current.stop() await recorder.current.flush() // clear records after download + // make sure to make this toast be the latest (although it's already stopped the recorder) toast.info('录制已中止。') } }, [ffmpeg]) + const screenshot = useCallback(() => { const video = document.querySelector(livePlayerVideo) as HTMLVideoElement if (video === null) { @@ -172,16 +187,11 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { }, []) - useKeyDown(recordKey.key, async (e) => { + useKeyDown(recordKey.key, (e) => { if (e.ctrlKey !== recordKey.ctrlKey) return if (e.shiftKey !== recordKey.shiftKey) return e.preventDefault() - try { - await clipRecord() - } catch (err: Error | any) { - console.error('unexpected error: ', err) - toast.error('未知错误: ' + err.message) - } + clipRecord() }) useKeyDown(screenshotKey.key, (e) => { diff --git a/src/features/recorder/recorders/buffer.ts b/src/features/recorder/recorders/buffer.ts index ce6b7f6e..bff2b330 100644 --- a/src/features/recorder/recorders/buffer.ts +++ b/src/features/recorder/recorders/buffer.ts @@ -1,78 +1,34 @@ -import db from "~database"; -import type { Streams } from "~database/tables/stream"; -import { recordStream } from "~players"; +import { recordStream, type PlayerOptions, type VideoInfo } from "~players"; import type { StreamPlayer } from "~types/media"; import { Recorder } from "~types/media"; import { type ChunkData } from "."; +import type { StreamUrls } from "~background/messages/get-stream-urls"; -class BufferRecorder extends Recorder { +class BufferRecorder extends Recorder { private player: StreamPlayer = null - private readonly fallbackChunks: Streams[] = [] - private errorHandler: (error: Error) => void = null - private bufferAppendChecker: NodeJS.Timeout = null + private info: VideoInfo = null async start(): Promise { let i = 0 this.player = await recordStream(this.urls, (buffer) => this.onBufferArrived(++i, buffer), this.options) - let lastRecordedSize = 0 - this.bufferAppendChecker = setInterval(() => { - if (!this.recording) { - clearInterval(this.bufferAppendChecker) - return - } - if (lastRecordedSize !== this.recordedSize) return - console.warn('buffer data has not been appended for 15 seconds! current recorded size: ', this.fileSize) - this.errorHandler?.(new Error('已超过15秒没再接收到数据流!你可能需要刷新页面')) - lastRecordedSize = this.recordedSize - }, 15000) + this.appendBufferChecker() + this.info = this.player.videoInfo } private async onBufferArrived(order: number, buffer: ArrayBuffer): Promise { const blob = new Blob([buffer], { type: 'application/octet-stream' }) - const stream = { - date: new Date().toISOString(), - content: blob, - order, - room: this.room - } - try { - await db.streams.add(stream) - console.debug('recorded segment: ', buffer.byteLength, 'bytes, order: ', stream.order) - } catch (err: Error | any) { - console.error('Error writing buffer to file', err) - console.warn('writing into fallback chunks') - this.fallbackChunks.push(stream) - } finally { - this.recordedSize += buffer.byteLength - } + return this.saveChunk(blob, order) } async loadChunkData(flush: boolean = true): Promise { - - const streams = await db.streams.where({ room: this.room }).sortBy('order') - if (flush) { - while (this.recordedSize >= (Recorder.FFmpegLimit - 1024) && streams.length > 0) { // 2GB - 1KB - console.info(`recorded size exceeds 2GB (${this.fileSize}), deleting oldest record`) - const { id, content } = streams.shift() - await db.streams.delete(id) - this.recordedSize -= content.size - } - } - const chunks = [...streams, ...this.fallbackChunks].toSorted((a, b) => a.order - b.order).map(c => c.content) + const chunks = await this.loadChunks(flush) return { chunks, - info: this.player.videoInfo + info: this.info } } - async flush(): Promise { - this.recordedSize = 0 - const re = await db.streams.where({ room: this.room }).delete() - this.fallbackChunks.length = 0 - console.debug('flushed ', re, ' records from databases') - } - stop(): void { clearInterval(this.bufferAppendChecker) this.player?.stopAndDestroy() diff --git a/src/features/recorder/recorders/capture.ts b/src/features/recorder/recorders/capture.ts new file mode 100644 index 00000000..8b76fddc --- /dev/null +++ b/src/features/recorder/recorders/capture.ts @@ -0,0 +1,149 @@ +import db from "~database"; +import type { VideoInfo } from "~players"; +import { Recorder } from "~types/media"; +import { injectFunction } from "~utils/inject"; +import { sleep } from "~utils/misc"; +import type { ChunkData } from "."; + +export type CaptureOptions = { + autoSwitchQuality?: boolean +} + +class CaptureRecorder extends Recorder { + + private static readonly Info: VideoInfo = { + mimeType: 'video/mp4', + extension: 'mp4', + } + + private recorder: MediaRecorder + private videoTrackChecker: NodeJS.Timeout + + async start(): Promise { + let video = await this.loadVideoElement() + if (this.options.autoSwitchQuality === true) { + video = await this.switchQuality(video) + } + if (video.muted) { + if (window.confirm('此录制方式需要使直播处于非静音状态,是否解除静音?')) { + video.muted = false + } else { + throw new Error('直播处于静音状态,无法录制。') + } + } + return this.startRecording(video) + } + + async loadChunkData(flush?: boolean): Promise { + const chunks = await this.loadChunks(flush) + return { chunks, info: CaptureRecorder.Info } + } + + stop(): void { + clearInterval(this.videoTrackChecker) + clearInterval(this.bufferAppendChecker) + this.recorder?.stop() + this.recorder = null + this._ticking = false + } + + get recording(): boolean { + return this.recorder?.state === 'recording' + } + + async loadVideoElement(): Promise { + const videos = [...document.querySelectorAll('video').values()].filter(v => v.captureStream) + if (videos.length === 0) { + console.warn('no video element found, waiting 2 seconds for video element...') + await sleep(2000) + return this.loadVideoElement() + } + console.debug('videos availables: ', videos) + const video = videos[0] + if (video.readyState === 4) { + return video + } + console.debug('video is not ready, waiting for load event...') + return new Promise((res, rej) => { + video.onloadeddata = () => res(video) + video.onplaying = () => res(video) + video.onerror = (e) => rej(e) + }) + } + + private startVideoTracker(video: HTMLVideoElement): void { + const id = video.id + this.videoTrackChecker = setInterval(async () => { + if (!this.recording) { + clearInterval(this.videoTrackChecker) + return + } + if (document.getElementById(id)) return + console.warn('视频源已被更改, 请刷新页面重新进行录制。') + const rows = (await db.streams.where({ room: this.room }).count()) + this.fallbackChunks.length + if (rows > 0) { + console.debug('found ', rows, ' buffer in database, avoid to stop recorder.') + this.errorHandler?.(new Error('视频源已被更改, 请刷新页面重新进行录制。')) + this._ticking = false + clearInterval(this.videoTrackChecker) + } else { + console.debug('no buffer found, restarting the recorder!') + this.stop() + await this.start() + clearInterval(this.bufferAppendChecker) + clearInterval(this.videoTrackChecker) + } + }, 1000) + } + + private async switchQuality(current: HTMLVideoElement): Promise { + let info = undefined + while (!info) { + await sleep(1000) + info = await injectFunction('invokeLivePlayer', 'getPlayerInfo') + } + console.debug('video info: ', info) + if (info.quality === '10000') { + console.debug('video quality is already 10000') + return current + } + await injectFunction('invokeLivePlayer', 'switchQuality', '10000') + console.info('switched live video quality to 10000') + console.debug('wait for video quality ready...') + let latest = await this.loadVideoElement() + while (latest.id === current.id) { + await sleep(1000) + latest = await this.loadVideoElement() + } + return latest + } + + private async startRecording(video: HTMLVideoElement): Promise { + video.crossOrigin = 'annoymous' + const stream = video.captureStream() + this.recorder = new MediaRecorder(stream, { + audioBitsPerSecond: 320000, + videoBitsPerSecond: 8000000, + mimeType: 'video/webm; codecs="h264, opus"' + }) + let order = 0 + this.recorder.ondataavailable = (e) => { + this.saveChunk(e.data, ++order) + } + this.recorder.onstart = () => { + console.debug('media recorder started') + } + this.recorder.onerror = (e) => { + console.error('media recorder error: ', e) + this.errorHandler?.(new Error('MediaRecorder error: ' + e.type)) + } + this.recorder.onstop = () => { + console.debug('media recorder stopped') + } + this.recorder.start(1000) + this.appendBufferChecker() + this.startVideoTracker(video) + } +} + +export default CaptureRecorder \ No newline at end of file diff --git a/src/features/recorder/recorders/index.ts b/src/features/recorder/recorders/index.ts index 0ba59356..62a63aa1 100644 --- a/src/features/recorder/recorders/index.ts +++ b/src/features/recorder/recorders/index.ts @@ -1,7 +1,8 @@ import type { StreamUrls } from "~background/messages/get-stream-urls" -import type { PlayerOptions, PlayerType, VideoInfo } from "~players" +import type { PlayerOptions, VideoInfo } from "~players" import { Recorder } from "~types/media" import buffer from "./buffer" +import capture, { type CaptureOptions } from "./capture" export type ChunkData = { chunks: Blob[] @@ -10,12 +11,17 @@ export type ChunkData = { export type RecorderType = keyof typeof recorders -const recorders = { - buffer +export type RecorderPayload = { + buffer: PlayerOptions + capture: CaptureOptions } +const recorders = { + buffer, + capture, +} -function createRecorder(room: string, urls: StreamUrls, type: RecorderType, options: PlayerOptions = { codec: 'avc' }): Recorder { +function createRecorder(room: string, urls: StreamUrls, type: T, options: RecorderPayload[T]): Recorder { const Recorder = recorders[type] if (!Recorder) { throw new Error('unsupported recorder type: ' + type) diff --git a/src/ffmpeg/core-mt.ts b/src/ffmpeg/core-mt.ts index 882a61ac..9c918e2d 100644 --- a/src/ffmpeg/core-mt.ts +++ b/src/ffmpeg/core-mt.ts @@ -1,4 +1,4 @@ -import type { FFMpegCore } from "~ffmpeg"; +import type { Cleanup, FFMpegCore } from "~ffmpeg"; import classWorkerURL from 'url:assets/ffmpeg/mt-worker.js' @@ -9,13 +9,15 @@ import coreURL from 'url:assets/ffmpeg/mt-core.js' import { toBlobURL } from '@ffmpeg/util'; import type { FFmpeg } from "@ffmpeg/ffmpeg"; import { getSettingStorage } from "~utils/storage"; +import { randomString } from "~utils/misc"; export class MultiThread implements FFMpegCore { + private ffmpeg: FFmpeg = null private divider = 0.5 // default divider async load(ffmpeg: FFmpeg): Promise { - + this.ffmpeg = ffmpeg try { await this.loadDivide() } catch (err: Error | any) { @@ -30,10 +32,58 @@ export class MultiThread implements FFMpegCore { }) } - get args(): string[] { + async fix(input: string, output: string, prepareCut: boolean): Promise { + if (!this.ffmpeg) throw new Error('FFmpeg not loaded') + + await this.ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', input, + '-c', 'copy', + ...(prepareCut ? [] : this.args), + output + ]) + + return async () => { + await this.ffmpeg.deleteFile(output) + } + } + + async cut(input: string, output: string, duration: number): Promise { + if (!this.ffmpeg) throw new Error('FFmpeg not loaded') + + const seconds = `${duration * 60}` + const temp = randomString() + + await this.ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-sseof', `-${seconds}`, + '-i', input, + ...this.args, + '-avoid_negative_ts', 'make_zero', + temp + output + ]) + + await this.ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', temp + output, + '-t', seconds, + '-c', 'copy', + output + ]) + + return async () => { + await this.ffmpeg.deleteFile(temp + output) + await this.ffmpeg.deleteFile(output) + } + } + + + private get args(): string[] { return [ + '-b:v', '0', '-threads', `${Math.round(window.navigator.hardwareConcurrency * this.divider)}`, '-vcodec', 'h264', + '-r', '60' ] } diff --git a/src/ffmpeg/core.ts b/src/ffmpeg/core.ts index ecc31864..ab48c413 100644 --- a/src/ffmpeg/core.ts +++ b/src/ffmpeg/core.ts @@ -1,14 +1,18 @@ -import type { FFMpegCore } from "~ffmpeg"; +import type { Cleanup, FFMpegCore } from "~ffmpeg"; import type { FFmpeg } from "@ffmpeg/ffmpeg"; import { toBlobURL } from "@ffmpeg/util"; import ffmpegWorkerJs from 'url:assets/ffmpeg/worker.js'; +import { randomString } from "~utils/misc"; const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm" export class SingleThread implements FFMpegCore { + private ffmpeg: FFmpeg = null + async load(ffmpeg: FFmpeg): Promise { + this.ffmpeg = ffmpeg return ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "application/javascript"), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), @@ -16,8 +20,51 @@ export class SingleThread implements FFMpegCore { }) } - get args(): string[] { - return ['-c', 'copy'] // in single thread mode, we just copy the input file to speed up the process + async fix(input: string, output: string, prepareCut: boolean): Promise { + if (!this.ffmpeg) throw new Error('FFmpeg not loaded') + + await this.ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', input, + '-c', 'copy', + ...(prepareCut ? [] : ['-r', '60']), + output + ]) + + return async () => { + await this.ffmpeg.deleteFile(output) + } + + } + + async cut(input: string, output: string, duration: number): Promise { + if (!this.ffmpeg) throw new Error('FFmpeg not loaded') + + const seconds = `${duration * 60}` + const temp = randomString() + + await this.ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-sseof', `-${seconds}`, + '-i', input, + '-r', '60', + '-avoid_negative_ts', 'make_zero', + '-c', 'copy', + temp + output + ]) + + await this.ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', temp + output, + '-t', seconds, + '-c', 'copy', + output + ]) + + return async () => { + await this.ffmpeg.deleteFile(temp + output) + await this.ffmpeg.deleteFile(output) + } } } diff --git a/src/ffmpeg/index.ts b/src/ffmpeg/index.ts index 3683f9bc..5c96d5f2 100644 --- a/src/ffmpeg/index.ts +++ b/src/ffmpeg/index.ts @@ -1,14 +1,16 @@ -import type { FFmpeg } from "@ffmpeg/ffmpeg" +import { FFmpeg } from "@ffmpeg/ffmpeg" import { isBackgroundScript } from "~utils/file" import coreSt from './core' import coreMt from './core-mt' +export type Cleanup = () => Promise + export interface FFMpegCore { load(ffmpeg: FFmpeg): Promise - get args(): string[] - + cut(input: string, output: string, duration: number): Promise + fix(input: string, output: string, prepareCut: boolean): Promise } function getFFMpegCore(): FFMpegCore { diff --git a/src/hooks/ffmpeg.ts b/src/hooks/ffmpeg.ts index 0b6e7cd1..900a41a8 100644 --- a/src/hooks/ffmpeg.ts +++ b/src/hooks/ffmpeg.ts @@ -72,19 +72,26 @@ export class FFMpegHooks implements Disposable { const needCut = duration > 0 console.debug('reading file size: ', formatBytes(input.size)) const original = await input.arrayBuffer() - const cutArgs = [...this.ffCore.args, '-b:v', '0', '-r', '60'] - const fixArgs = needCut ? ['-c', 'copy'] : cutArgs // 如需剪輯,則在第一次執行一律使用快速編譯 await this.ffmpeg.writeFile(inputFile, new Uint8Array(original)) + + const cleanups = [ + async () => { + await this.ffmpeg.deleteFile(inputFile) + }, + ] + this.stage = 'fix' - console.debug('fixArg: ', fixArgs) - await this.ffmpeg.exec(['-i', inputFile, ...fixArgs, middleFile]) + cleanups.push(await this.ffCore.fix(inputFile, middleFile, needCut)) if (needCut) { this.stage = 'cut' - console.debug('cutArg: ', cutArgs) - await this.ffmpeg.exec(['-sseof', `-${duration * 60}`, '-i', middleFile, ...cutArgs, outputFile]) + cleanups.push(await this.ffCore.cut(middleFile, outputFile, duration)) } const data = await this.ffmpeg.readFile(needCut ? outputFile : middleFile) - return (data as Uint8Array).buffer + const buffer = (data as Uint8Array).buffer + console.debug('cleaning up files...') + await Promise.all(cleanups.map(cleanup => cleanup())) + console.debug('files cleaned up') + return buffer } /** diff --git a/src/options/features/recorder/index.tsx b/src/options/features/recorder/index.tsx index 24af15fe..f15ca4f6 100644 --- a/src/options/features/recorder/index.tsx +++ b/src/options/features/recorder/index.tsx @@ -24,6 +24,7 @@ export type FeatureSettingSchema = { hiddenUI: boolean threads: number overflow: 'limit' | 'skip' + autoSwitchQuality: boolean } export const defaultSettings: Readonly = { @@ -43,7 +44,8 @@ export const defaultSettings: Readonly = { mechanism: 'buffer', hiddenUI: false, threads: 0.5, - overflow: 'limit' + overflow: 'limit', + autoSwitchQuality: false } export function RecorderFeatureSettings({ state, useHandler }: StateProxy): JSX.Element { @@ -67,6 +69,7 @@ export function RecorderFeatureSettings({ state, useHandler }: StateProxy state.outputType = e} options={[ { value: 'hls', label: 'MP4' }, @@ -74,6 +77,33 @@ export function RecorderFeatureSettings({ state, useHandler }: StateProxy + + data-testid="record-mechanism" + label="录制方式" + value={state.mechanism} + onChange={e => { + if (e === 'capture') { + state.outputType = 'hls' + toast.info('捕捉直播流媒体元素將使用当前直播的画质录制,录制前先记得调至原画。', { + position: 'top-center', + action: { + label: '启用自动切换到原画', + onClick: () => { + state.autoSwitchQuality = true + toast.success('已启用自动切换到原画画质功能。') + } + } + }) + } else { + state.autoSwitchQuality = false + } + state.mechanism = e + }} + options={[ + { value: 'buffer', label: '另开直播线路录制' }, + { value: 'capture', label: '捕捉直播流媒体元素' } + ]} + /> data-testid="record-fix" label="录制后编译方式" diff --git a/src/players/hls.ts b/src/players/hls.ts index bcdac340..d146ae99 100644 --- a/src/players/hls.ts +++ b/src/players/hls.ts @@ -1,10 +1,11 @@ -import Hls from 'hls.js'; +import Hls, { LevelDetails } from 'hls.js'; import { type VideoInfo } from "~players"; import { StreamPlayer } from '~types/media'; class HlsPlayer extends StreamPlayer { private player: Hls + private bufferFlushChecker: NodeJS.Timeout get isSupported(): boolean { return Hls.isSupported() @@ -28,53 +29,77 @@ class HlsPlayer extends StreamPlayer { media = document.createElement('video') media.style.display = 'none' media.volume = 0 - media.muted = true + media.muted = true // after muted, the auto clean back buffer will not work in hls.js, so we need to manually check media.autoplay = true } this.player = new Hls({ - enableWorker: true, - liveDurationInfinity: true, lowLatencyMode: true, maxBufferLength: Infinity, + liveDurationInfinity: true, backBufferLength: 30 }) return new Promise((res, rej) => { + this.player.once(Hls.Events.MEDIA_ATTACHED, () => { console.log('video and hls.js are now bound together !') - }) this.player.once(Hls.Events.MANIFEST_PARSED, (event, data) => { console.log('manifest loaded, found ' + data.levels.length + ' quality level', data) this.emit('loaded', {}) res() + this.bufferFlushChecker = setInterval(() => { + const tr = media.buffered + if (tr.length < 1) return + this.checkBufferShouldFlush(tr) + }, this.player.config.backBufferLength * 500) }) this.player.on(Hls.Events.BUFFER_APPENDING, (event, data) => { this.emit('buffer', data.data.buffer) }) + this.player.on(Hls.Events.BUFFER_APPENDED, (event, data) => { + const tr = data.timeRanges.audiovideo + if (tr.length < 1) return + console.debug('start: ', tr.start(0), ', end: ', tr.end(tr.length - 1), ', gap: ', tr.end(tr.length - 1) - tr.start(0)) + }) + this.player.loadSource(url); this.player.attachMedia(media); + this.player.on(Hls.Events.BUFFER_FLUSHING, (event, data) => { + console.debug('buffer flushing for type: ', data.type, ', start: ', data.startOffset, ', end: ', data.endOffset) + }) + let retryCount = 0 this.player.on(Hls.Events.ERROR, (event, data) => { console.warn('hls error: ', data.error, data.errorAction, data.reason) - console.warn('retry count: ', retryCount++) - if (retryCount >= 3) { - console.error('retry count exceeded, stop and destroy player') - this.stopAndDestroy() - this.emit('error', data.error) - return + + if (data.details === Hls.ErrorDetails.BUFFER_FULL_ERROR) { + console.warn('buffer full error encountered, trying to flush all buffer') + this.player.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: 0, + endOffset: media.buffered.end(media.buffered.length - 1) - 60, + type: 'audiovideo' + }) } - + if (data.fatal) { + // only fatal error will trigger retry, otherwise ignore + console.warn('retry count: ', ++retryCount) + if (retryCount >= 3) { + console.error('retry count exceeded, stop and destroy player') + this.emit('error', data.error) + this.stopAndDestroy() + return + } switch (data.type) { case Hls.ErrorTypes.MEDIA_ERROR: - console.log('fatal media error encountered, try to recover'); - if (media) this.player.recoverMediaError() + console.error('fatal media error encountered, try to recover'); + this.player.recoverMediaError() break; case Hls.ErrorTypes.NETWORK_ERROR: console.error('fatal network error encountered', data); @@ -88,12 +113,14 @@ class HlsPlayer extends StreamPlayer { break; } rej(data.error) + } }) }) } stopAndDestroy() { + clearInterval(this.bufferFlushChecker) this.clearHandlers() this.player.stopLoad() this.player.detachMedia() @@ -101,6 +128,23 @@ class HlsPlayer extends StreamPlayer { this.player = null } + // for recording only + private checkBufferShouldFlush(tr: TimeRanges) { + if (tr.length < 1) return + const start = tr.start(0) + const gap = tr.end(tr.length - 1) - start + const maxBuffer = this.player.config.backBufferLength * 1.3 + // if the hlsjs back buffer flushing is working, this gap will never larger than maxBuffer + // so only will trigger on recording + if (gap <= maxBuffer) return + console.debug('buffer gap is larger than max buffer, flushing buffer...') + this.player.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: 0, + endOffset: start + (gap - maxBuffer), + type: 'audiovideo' + }) + } + } export default HlsPlayer \ No newline at end of file diff --git a/src/players/index.ts b/src/players/index.ts index cb039236..8fd0476b 100644 --- a/src/players/index.ts +++ b/src/players/index.ts @@ -1,4 +1,4 @@ -import type { StreamUrls } from '~background/messages/get-stream-urls' +import type { StreamUrl, StreamUrls } from '~background/messages/get-stream-urls' import flv from './flv' import hls from './hls' import type { StreamPlayer } from '~types/media' @@ -31,10 +31,12 @@ export type PlayerOptions = { codec?: 'avc' | 'hevc' } -async function loadStream(urls: StreamUrls, video: HTMLVideoElement, options: PlayerOptions = { codec: 'avc' }): Promise { - for (const url of urls) { - if (options.type && url.type !== options.type) continue - if (options.codec && url.codec !== options.codec) continue +async function loopStreams(urls: StreamUrls, handler: (p: StreamPlayer, url: StreamUrl) => Promise, options: PlayerOptions = { codec: 'avc' }) { + const availables = urls + .filter(url => options.type ? url.type === options.type : true) + .filter(url => options.codec ? url.codec === options.codec : true) + if (availables.length === 0) throw new Error('没有可用的视频流URL') + for (const url of availables) { const Player = players[url.type] const player = new Player() console.info(`trying to use type ${url.type} player to load: `, url.url, ' quality: ', url.quality, ' codec: ', url.codec) @@ -43,37 +45,33 @@ async function loadStream(urls: StreamUrls, video: HTMLVideoElement, options: Pl continue } try { - await player.play(url.url, video) + await handler(player, url) return player } catch (err: Error | any) { console.error(`Player failed to load: `, err, ', from: ', url) continue } } - throw new Error('No player is supported') + throw new Error('没有可用的播放器支援 ' + JSON.stringify(options)) +} + +export async function loadStream(urls: StreamUrls, video: HTMLVideoElement, options: PlayerOptions = { codec: 'avc' }): Promise { + return loopStreams( + urls, + (p, url) => p.play(url.url, video), + options + ) } export async function recordStream(urls: StreamUrls, handler: EventHandler<'buffer'>, options: PlayerOptions = { codec: 'avc' }): Promise { - for (const url of urls) { - if (options.type && url.type !== options.type) continue - if (options.codec && url.codec !== options.codec) continue - const Player = players[url.type] - const player = new Player() - console.info(`trying to use type ${url.type} player to record: `, url.url, ' quality: ', url.quality, ' codec: ', url.codec) - if (!player.isSupported) { - console.warn(`Player ${url.type} is not supported, skipped: `, url) - continue - } - try { - await player.play(url.url) - player.on('buffer', handler) - return player - } catch (err: Error | any) { - console.error(`Player failed to load: `, err, ', from: ', url) - continue - } - } - throw new Error('No recorder is supported') + return loopStreams( + urls, + async (p, url) => { + p.on('buffer', handler) + await p.play(url.url) + }, + options + ) } export default loadStream \ No newline at end of file diff --git a/src/tabs/encoder.tsx b/src/tabs/encoder.tsx index 7ed86437..48db25c9 100644 --- a/src/tabs/encoder.tsx +++ b/src/tabs/encoder.tsx @@ -133,7 +133,8 @@ function Converter(props: ConverterProps): JSX.Element { } console.debug('progress: ', progress) - const progressInvalid = isNaN(progress.progress) || !isFinite(progress.progress) + const progressInvalid = progress.progress > 1 || progress.progress < 0 + return (
diff --git a/src/tabs/stream.tsx b/src/tabs/stream.tsx index 837782c6..92748b22 100644 --- a/src/tabs/stream.tsx +++ b/src/tabs/stream.tsx @@ -52,9 +52,12 @@ function MonitorApp({ urls }: { urls: StreamUrls }): JSX.Element { useAsyncEffect( async () => { console.info('urls: ', urls) - const p = await loadStream(urls, videoRef.current) + const p = await loadStream(urls, videoRef.current, { codec: 'avc' }) danmaku.current = new NDanmaku(containerRef.current, 'bjf-danmaku') - p.on('error', console.error) + p.on('error', err => { + console.error(err) + alert('播放錯誤, 请刷新页面: '+err?.message) + }) return p }, async (p) => { @@ -72,7 +75,7 @@ function MonitorApp({ urls }: { urls: StreamUrls }): JSX.Element { }, (err) => { console.error(err) - alert('加载播放器失败, 请刷新页面') + alert('加载播放器失败, 请刷新页面: '+err?.message) }, [urls] ) diff --git a/src/types/extends/global.d.ts b/src/types/extends/global.d.ts index 7c04564b..4d727f4d 100644 --- a/src/types/extends/global.d.ts +++ b/src/types/extends/global.d.ts @@ -42,6 +42,10 @@ declare global { remove(options?: { recursive: boolean }): Promise; } + interface HTMLMediaElement{ + captureStream(): MediaStream; + } + } export { } \ No newline at end of file diff --git a/src/types/media/recorder.ts b/src/types/media/recorder.ts index a4ca1f2f..ec0842ff 100644 --- a/src/types/media/recorder.ts +++ b/src/types/media/recorder.ts @@ -1,34 +1,40 @@ import type { StreamUrls } from "~background/messages/get-stream-urls" -import type { PlayerOptions, PlayerType } from "~players" +import db from "~database" +import type { Stream } from "~database/tables/stream" import type { ChunkData } from "~features/recorder/recorders" import { formatBytes } from "~utils/binary" -export abstract class Recorder { +export abstract class Recorder { protected static readonly FFmpegLimit = 2 * 1024 * 1024 * 1024 // 2GB - protected readonly room: string - protected readonly urls: StreamUrls - protected readonly options: PlayerOptions + protected _ticking: boolean | null = null protected recordedSize = 0 - - constructor(room: string, urls: StreamUrls, options: PlayerOptions) { - this.room = room - this.urls = urls - this.options = options - } + protected fallbackChunks: Stream[] = [] + protected errorHandler: (error: Error) => void = null + protected bufferAppendChecker: NodeJS.Timeout = null + + constructor( + protected readonly room: string, + protected readonly urls: StreamUrls, + protected readonly options: Options + ) { } abstract start(): Promise abstract loadChunkData(flush?: boolean): Promise - abstract flush(): Promise - abstract stop(): void abstract get recording(): boolean - abstract set onerror(handler: (error: Error) => void) + get ticking(): boolean { + return this._ticking ?? this.recording + } + + set onerror(handler: (error: Error) => void) { + this.errorHandler = handler + } get fileSize(): string { return formatBytes(this.recordedSize) @@ -37,4 +43,64 @@ export abstract class Recorder { get fileSizeMB(): number { return this.recordedSize / (1024 * 1024) } + + async flush(): Promise { + this.recordedSize = 0 + const re = await db.streams.where({ room: this.room }).delete() + this.fallbackChunks.length = 0 + console.debug('flushed ', re, ' records from databases') + } + + protected appendBufferChecker(): void { + let lastRecordedSize = 0 + this._ticking = null + this.bufferAppendChecker = setInterval(() => { + if (!this.recording) { + clearInterval(this.bufferAppendChecker) + return + } + try { + if (lastRecordedSize !== this.recordedSize) return + console.warn('buffer data has not been appended for 15 seconds! current recorded size: ', this.fileSize) + this.errorHandler?.(new Error('已超过15秒没再接收到数据流!你可能需要刷新页面')) + this._ticking = false + } finally { + lastRecordedSize = this.recordedSize + } + + }, 15000) + } + + protected async saveChunk(blob: Blob, order: number): Promise { + const stream = { + date: new Date().toISOString(), + content: blob, + order, + room: this.room + } + try { + await db.streams.add(stream) + console.debug('recorded segment: ', blob.size, 'bytes, order: ', stream.order) + } catch (err: Error | any) { + console.error('Error writing buffer to file', err) + console.warn('writing into fallback chunks') + this.fallbackChunks.push(stream) + } finally { + this.recordedSize += blob.size + } + } + + protected async loadChunks(flush: boolean = true): Promise { + const streams = await db.streams.where({ room: this.room }).sortBy('order') + if (flush) { + while (this.recordedSize >= (Recorder.FFmpegLimit - 1024) && streams.length > 0) { // 2GB - 1KB + console.info(`recorded size exceeds 2GB (${this.fileSize}), deleting oldest record`) + const { id, content } = streams.shift() + await db.streams.delete(id) + this.recordedSize -= content.size + } + } + return [...streams, ...this.fallbackChunks].toSorted((a, b) => a.order - b.order).map(c => c.content) + } + } \ No newline at end of file diff --git a/tests/features/jimaku.spec.ts b/tests/features/jimaku.spec.ts index d971c4ec..357ff676 100644 --- a/tests/features/jimaku.spec.ts +++ b/tests/features/jimaku.spec.ts @@ -2,7 +2,7 @@ import type { Locator } from '@playwright/test' import { expect, test } from '@tests/fixtures/content' import logger from '@tests/helpers/logger' import { isFrame, type PageFrame } from '@tests/helpers/page-frame' -import { testFeatureRoomList } from '@tests/utils/playwright' +import { selectOption, testFeatureRoomList } from '@tests/utils/playwright' import { readText } from 'tests/utils/file' test.beforeEach(async ({ content: p }) => { @@ -335,8 +335,12 @@ test('測試保存設定後 css 能否生效', async ({ context, content, option await settingsPage.getByTestId('jimaku-size').fill('30') await settingsPage.getByTestId('jimaku-first-size').fill('30') await settingsPage.getByTestId('jimaku-bg-height').fill('500') - await settingsPage.getByTestId('jimaku-position').locator('div > div').nth(0).click() - await settingsPage.getByText('置左').click() + + await selectOption( + settingsPage.getByTestId('jimaku-position'), + '置左' + ) + await settingsPage.getByTestId('jimaku-color').fill('#123456') diff --git a/tests/features/recorder.spec.ts b/tests/features/recorder.spec.ts index fb27cadb..67a36d71 100644 --- a/tests/features/recorder.spec.ts +++ b/tests/features/recorder.spec.ts @@ -1,7 +1,8 @@ import { test, expect } from "@tests/fixtures/content" import logger from "@tests/helpers/logger" +import type { PageFrame } from "@tests/helpers/page-frame" import { readJpeg, readMovieInfo } from "@tests/utils/file" -import { testFeatureRoomList } from "@tests/utils/playwright" +import { selectOption, testFeatureRoomList } from "@tests/utils/playwright" import fs from 'fs/promises' test.beforeEach(async ({ content, context, optionPageUrl, isThemeRoom }) => { @@ -123,6 +124,8 @@ test('測試截圖', async ({ content, page }) => { test('測試錄製 FLV', async ({ content, page, context, optionPageUrl }) => { test.slow() + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') logger.info('正在修改設定...') const settingsPage = await context.newPage() @@ -130,14 +133,16 @@ test('測試錄製 FLV', async ({ content, page, context, optionPageUrl }) => { await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() - await settingsPage.getByTestId('record-output-type').locator('div > div').nth(0).click() - await settingsPage.getByText('FLV').click() + await selectOption( + settingsPage.getByTestId('record-output-type'), + 'FLV' + ) await settingsPage.getByText('保存设定').click() await settingsPage.close() + await timer.waitFor({ state: 'visible' }) logger.info('正在錄製...') - const button = content.getByTestId('record-button') await content.waitForTimeout(30000) const download = page.waitForEvent('download') @@ -152,7 +157,7 @@ test('測試錄製 FLV', async ({ content, page, context, optionPageUrl }) => { logger.info('視頻信息:', info) - expect(info.relativeDuration()).toBeGreaterThan(30) + expect(info.relativeDuration()).toBeGreaterThanOrEqual(30) }) test('測試錄製 HLS', async ({ content, page }) => { @@ -176,7 +181,55 @@ test('測試錄製 HLS', async ({ content, page }) => { logger.info('視頻信息:', info) - expect(info.relativeDuration()).toBeGreaterThan(30) + expect(info.relativeDuration()).toBeGreaterThanOrEqual(30) +}) + +test('測試錄製 WEBM -> MP4', async ({ content, page, context, optionPageUrl }) => { + + test.slow() + + page.on('dialog', d => { + if (d.message().includes('解除静音')) { + d.accept() + } + }) + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await selectOption( + settingsPage.getByTestId('record-mechanism'), + '捕捉直播流媒体元素' + ) + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + //const content = await room.reloadAndGetLocator() + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + await timer.waitFor({ state: 'visible' }) + logger.info('正在錄製...') + await sleepAndCheckBuffer(content, 30) + + const download = page.waitForEvent('download') + await button.click() + await expect(content.getByText('视频下载成功。')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + await downloaded.saveAs('out/recording.mp4') + + const info = await readMovieInfo('out/recording.mp4') + + logger.info('視頻信息:', info) + + // -1 gap + expect(info.relativeDuration()).toBeGreaterThanOrEqual(30 - 1) }) test('測試熱鍵錄製', async ({ page, optionPageUrl, context, content }) => { @@ -220,7 +273,7 @@ test('測試熱鍵錄製', async ({ page, optionPageUrl, context, content }) => logger.info('視頻信息:', info) - expect(info.relativeDuration()).toBeGreaterThan(30) + expect(info.relativeDuration()).toBeGreaterThanOrEqual(30) }) test('測試熱鍵截圖', async ({ page, content, context, optionPageUrl }) => { @@ -232,7 +285,7 @@ test('測試熱鍵截圖', async ({ page, content, context, optionPageUrl }) => await settingsPage.getByText('功能设定').click() const input = settingsPage.getByTestId('screenshot-hotkey') - + logger.info('正在測試按鍵衝突阻止...') await input.click() await expect(input).toHaveValue('监听输入中...') @@ -267,7 +320,6 @@ test('測試熱鍵截圖', async ({ page, content, context, optionPageUrl }) => expect(info.height).toBeGreaterThanOrEqual(480) }) - test('測試錄製時長', async ({ content, page }) => { // 10 mins: 6 mins recording + 4 mins operations @@ -279,10 +331,14 @@ test('測試錄製時長', async ({ content, page }) => { // default using 5 mins duration, so use 6 mins here await timer.waitFor({ state: 'visible' }) logger.info('正在錄製...') - await page.waitForTimeout(360000) + await sleepAndCheckBuffer(content, 360) // timer should be fixed on 5 mins - await expect(timer).toHaveText('00:05:00') + await expect.poll(() => timer.textContent(), { + message: '錄製時長未達到 5 分鐘或顯示沒有固定到 5 分鐘', + timeout: 30000, + }).toBe('00:05:00') + const download = page.waitForEvent('download') await button.click() @@ -303,7 +359,6 @@ test('測試錄製時長', async ({ content, page }) => { }) - test('測試手動錄製', async ({ content, page, context, optionPageUrl }) => { test.slow() @@ -314,8 +369,10 @@ test('測試手動錄製', async ({ content, page, context, optionPageUrl }) => await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() - await settingsPage.getByTestId('record-duration').locator('div > div').nth(0).click() - await settingsPage.getByText('手动录制').click() + await selectOption( + settingsPage.getByTestId('record-duration'), + '手动录制' + ) await settingsPage.getByText('保存设定').click() await settingsPage.close() @@ -347,7 +404,6 @@ test('測試手動錄製', async ({ content, page, context, optionPageUrl }) => }) - test('測試 HLS 完整編譯', async ({ content, page, context, optionPageUrl }) => { // I bet 10 mins for this @@ -359,8 +415,10 @@ test('測試 HLS 完整編譯', async ({ content, page, context, optionPageUrl } await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() - await settingsPage.getByTestId('record-fix').locator('div > div').nth(0).click() - await settingsPage.getByText('完整编译').click() + await selectOption( + settingsPage.getByTestId('record-fix'), + '完整编译' + ) await settingsPage.getByText('保存设定').click() await settingsPage.close() @@ -426,7 +484,6 @@ test('測試 HLS 完整編譯', async ({ content, page, context, optionPageUrl } expect(info.relativeDuration()).toBeGreaterThanOrEqual(10) }) - test('測試 FLV 完整編譯', async ({ content, page, context, optionPageUrl }) => { // I bet 10 mins for this @@ -439,11 +496,15 @@ test('測試 FLV 完整編譯', async ({ content, page, context, optionPageUrl } await settingsPage.getByText('功能设定').click() // change to flv first - await settingsPage.getByTestId('record-output-type').locator('div > div').nth(0).click() - await settingsPage.getByText('FLV').click() + await selectOption( + settingsPage.getByTestId('record-output-type'), + 'FLV' + ) - await settingsPage.getByTestId('record-fix').locator('div > div').nth(0).click() - await settingsPage.getByText('完整编译').click() + await selectOption( + settingsPage.getByTestId('record-fix'), + '完整编译' + ) await settingsPage.getByText('保存设定').click() await settingsPage.close() @@ -507,4 +568,211 @@ test('測試 FLV 完整編譯', async ({ content, page, context, optionPageUrl } logger.info('視頻信息:', info) expect(info.relativeDuration()).toBeGreaterThanOrEqual(10) -}) \ No newline at end of file +}) + +test('測試 WEBM -> MP4 完整編譯', async ({ content, page, context, optionPageUrl }) => { + // I bet 10 mins for this + test.setTimeout(600000) + + page.on('dialog', d => { + if (d.message().includes('解除静音')) { + d.accept() + } + }) + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await selectOption( + settingsPage.getByTestId('record-fix'), + '完整编译' + ) + + await selectOption( + settingsPage.getByTestId('record-mechanism'), + '捕捉直播流媒体元素' + ) + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + await timer.waitFor({ state: 'visible' }) + logger.info('正在錄製...') + + await page.waitForTimeout(12000) // gap + + const encoderPage = context.waitForEvent('page') + await button.click() + const encoder = await encoderPage + encoder.on('console', msg => logger.info('[encoder.html]', msg.text())) + + const frontend = (async () => { + await expect(content.getByText('准备视频中...')).toBeVisible() + await expect(content.getByText('视频已发送到后台进行完整编码')).toBeVisible({ + timeout: 30000 // longer wait + }) + })(); + + const backend = (async () => { + await expect(encoder.getByText('正在加载 FFMpeg')).toBeVisible() + await expect(encoder.getByText('FFMpeg 已成功加载。')).toBeVisible() + + try { + await expect(encoder.getByText('正在等待视频数据')).toBeVisible() + } catch { + logger.warn('由於檔案過小,獲取視頻數據飛快,快到看不見 ._.') + } + + })(); + + + await Promise.all([frontend, backend]) + await encoder.bringToFront() + + try { + await expect(encoder.getByText('修复视频中')).toBeVisible() + await expect(encoder.getByText('视频已修复完成。')).toBeVisible() + } catch { + logger.warn('由於檔案過小,修復視頻飛快,快到看不見 ._.') + } + + await expect(encoder.getByText('编译视频中')).toBeVisible() + + const downloaded = await encoder.waitForEvent('download', { + timeout: 600000 + }) + + await expect(encoder.getByText('视频已编译完成。')).toBeVisible() + expect(encoder.getByText(downloaded.suggestedFilename())).toBeVisible() + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + + await downloaded.saveAs('out/recording.mp4') + const info = await readMovieInfo('out/recording.mp4') + + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThanOrEqual(10) +}) + +test('測試 WEBM 錄製 - 更換視頻源時的處理', async ({ content, page, api, room, optionPageUrl, context }) => { + + test.slow() + + const urls = await api.getStreamUrls(room.info.roomid) + const qualities = new Set([...urls.filter(s => s.codec === 'avc').map(s => s.quality)]) + test.skip(qualities.size === 0, '無法獲取畫質') + test.skip(qualities.size === 1, '只有一種畫質,無法測試') + + logger.debug('available qualities: ', [...qualities]) + + page.on('dialog', d => { + if (d.message().includes('解除静音')) { + d.accept() + } + }) + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await selectOption( + settingsPage.getByTestId('record-mechanism'), + '捕捉直播流媒体元素' + ) + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + await timer.waitFor({ state: 'visible' }) + logger.info('正在錄製...') + + await page.waitForTimeout(10000) + + await page.evaluate(async (qualities) => { + + const self = window as any + const current = self.$P2PLivePlayer.getPlayerInfo().quality + const next = qualities.find(q => q.toString() !== current.toString()) + console.debug('current quality: ', current) + console.debug('switching to quality: ', next) + + self.$P2PLivePlayer.switchQuality(next.toString()) + + }, [ ...qualities ]) + + logger.info('正在檢查有否視頻源更改的錯誤信息...') + await expect(content.getByText('视频源已被更改, 请刷新页面重新进行录制')).toBeVisible({ + timeout: 30000 + }) + + logger.info('正在檢查時間戳有否停止...') + const currentText = await timer.textContent() + await content.waitForTimeout(3000) + const newText = await timer.textContent() + expect(currentText).toEqual(newText) + + logger.info('正在檢查已錄製的視頻數據能否依然下載...') + const download = page.waitForEvent('download') + await button.click() + await expect(content.getByText('视频下载成功。')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + await downloaded.saveAs('out/recording.mp4') + + const info = await readMovieInfo('out/recording.mp4') + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThanOrEqual(5) +}) + +test('測試 WEBM 錄製 - 不解除靜音的處理', async ({ content, page, api, room, optionPageUrl, context }) => { + + page.on('dialog', d => { + if (d.message().includes('解除静音')) { + d.dismiss() + } + }) + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await selectOption( + settingsPage.getByTestId('record-mechanism'), + '捕捉直播流媒体元素' + ) + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + logger.info('正在等待靜音提示...') + await expect(content.getByText('直播处于静音状态,无法录制')).toBeVisible() + await expect(content.getByTestId('record-timer')).toBeHidden() +}) + +async function sleepAndCheckBuffer(content: PageFrame, seconds: number): Promise { + let times = 0 + while (times < seconds) { + if (await content.getByText('已超过15秒没再接收到数据流').isVisible()) { + logger.error('no buffer received on seconds: ', times) + throw new Error('已超过15秒没再接收到数据流') + } + await content.waitForTimeout(1000) + times++ + } + logger.info('After seconds: ', times) +} \ No newline at end of file diff --git a/tests/integrations/player.spec.ts b/tests/integrations/player.spec.ts deleted file mode 100644 index 3554edaf..00000000 --- a/tests/integrations/player.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { expect, test } from "@tests/fixtures/component"; -import logger from "@tests/helpers/logger"; -import { readMovieInfo } from "@tests/utils/file"; - -// because they both use single-threaded, so -c copy is needed for boosting time - -test( - '測試透過 Buffer 錄製 HLS 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', - async ({ room: { stream }, page, modules }) => { - - await modules['player'].loadToPage() - await modules['utils'].loadToPage() - - const downloading = page.waitForEvent('download') - const length = await page.evaluate(async ({ stream }) => { - - const { player, utils } = window as any - - const chunks = [] - const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { - const blob = new Blob([buffer], { type: 'application/octet-stream' }) - chunks.push(blob) - console.info('total chunks size: ', chunks.length, buffer.byteLength) - }, { - type: 'hls', - codec: 'avc' - }) - - await utils.misc.sleep(15000) - - console.info('cleaning up stream buffer...') - p.stopAndDestroy() - - utils.file.download('test.mp4', [...chunks], 'video/mp4') - - { - // for next use - (window as any).testVideo = chunks; - } - - return chunks.length - - }, { stream }) - - expect(length).toBeGreaterThanOrEqual(15) - const downloaded = await downloading - await downloaded.saveAs('out/test.mp4') - const info = await readMovieInfo('out/test.mp4') - - logger.info('info: ', info) - - expect(info.relativeDuration() || 0).toBe(0) // broken info - - // now trying to fix broken info with ffmpeg - - await modules['ffmpeg'].loadToPage() - - const downloadingFix = page.waitForEvent('download') - await page.evaluate(async () => { - const { testVideo, getFFmpeg, utils } = window as any - const ffmpeg = await getFFmpeg() - console.log('ffmpeg loaded. converting file....') - const input = await new Blob(testVideo, { type: 'video/mp4' }).arrayBuffer() - await ffmpeg.writeFile('input.mp4', new Uint8Array(input)) - console.log('input file written, executing....') - await ffmpeg.exec(['-i', 'input.mp4', '-c', 'copy', 'output-uncut.mp4']) - await ffmpeg.exec(['-sseof', '-15', '-i', 'output-uncut.mp4', '-c', 'copy', 'output.mp4']) - console.log('output file written, downloading....') - const data = await ffmpeg.readFile('output.mp4') - const output = new Blob([data], { type: 'video/mp4' }) - utils.file.downloadBlob(output, 'fixed.mp4') - }) - - const downloadedFix = await downloadingFix - await downloadedFix.saveAs('out/fixed.mp4') - const infoFix = await readMovieInfo('out/fixed.mp4') - - logger.info('infoFix: ', infoFix) - logger.info('duration:', infoFix.relativeDuration()) - - expect(Math.round(infoFix.relativeDuration())).toBe(15) // fixed info - } -) - - -test( - '測試透過 Buffer 錄製 FLV 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', - async ({ room: { stream }, page, modules }) => { - - await modules['player'].loadToPage() - await modules['utils'].loadToPage() - - const downloading = page.waitForEvent('download') - const length = await page.evaluate(async ({ stream }) => { - - const { player, utils } = window as any - - const chunks = [] - const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { - const blob = new Blob([buffer], { type: 'application/octet-stream' }) - chunks.push(blob) - console.info('total chunks size: ', chunks.length, buffer.byteLength) - }, { - type: 'flv', - codec: 'avc' - }) - - await utils.misc.sleep(15000) - - console.info('cleaning up stream buffer...') - p.stopAndDestroy() - - utils.file.download('test.flv', [...chunks], 'video/x-flv') - - { - // for next use - (window as any).testVideo = chunks; - } - - return chunks.length - - }, { stream }) - - expect(length).toBeGreaterThanOrEqual(15) - const downloaded = await downloading - await downloaded.saveAs('out/test.flv') - const info = await readMovieInfo('out/test.flv') - - logger.info('info: ', info) - - expect(info.relativeDuration() || 0).toBe(0) // broken info - - // now trying to fix broken info with ffmpeg - - await modules['ffmpeg'].loadToPage() - - const downloadingFix = page.waitForEvent('download') - await page.evaluate(async () => { - const { testVideo, getFFmpeg, utils } = window as any - const ffmpeg = await getFFmpeg() - console.log('ffmpeg loaded. converting file....') - const input = await new Blob(testVideo, { type: 'video/x-flv' }).arrayBuffer() - await ffmpeg.writeFile('input.flv', new Uint8Array(input)) - console.log('input file written, executing....') - await ffmpeg.exec(['-i', 'input.flv', '-c', 'copy', 'output-uncut.flv']) - await ffmpeg.exec(['-sseof', '-15', '-i', 'output-uncut.flv', '-c', 'copy', 'output.flv']) - console.log('output file written, downloading....') - const data = await ffmpeg.readFile('output.flv') - const output = new Blob([data], { type: 'video/x-flv' }) - utils.file.downloadBlob(output, 'fixed.flv') - }) - - const downloadedFix = await downloadingFix - await downloadedFix.saveAs('out/fixed.flv') - const infoFix = await readMovieInfo('out/fixed.flv') - - logger.info('infoFix: ', infoFix) - logger.info('duration:', infoFix.relativeDuration()) - - expect(Math.round(infoFix.relativeDuration())).toBeGreaterThanOrEqual(15) // fixed info, but using copy will not cut the time precisely - - } - -) diff --git a/tests/integrations/recorder.spec.ts b/tests/integrations/recorder.spec.ts new file mode 100644 index 00000000..5ba9bccd --- /dev/null +++ b/tests/integrations/recorder.spec.ts @@ -0,0 +1,425 @@ +import { expect, test } from "@tests/fixtures/component"; +import logger from "@tests/helpers/logger"; +import { readMovieInfo } from "@tests/utils/file"; + +// because they both use single-threaded, so -c copy is needed for boosting time + +test( + '測試透過 Buffer 錄製 HLS 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', + async ({ room: { stream, roomid }, page, modules }) => { + + await modules['recorder'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async ({ stream, roomid }) => { + + const { createRecorder, utils } = window as any + + const recorder = createRecorder(roomid, stream, + 'buffer', + { + type: 'hls', + codec: 'avc' + } + ) + + await recorder.start() + await utils.misc.sleep(30000) + + const { chunks } = await recorder.loadChunkData() + utils.file.download('test.mp4', [...chunks], 'video/mp4') + + console.info('cleaning up stream buffer...') + await recorder.stop() + await recorder.flush() + + { + // for next use + (window as any).testVideo = chunks; + } + + return chunks.length + + }, { stream, roomid }) + + expect(length).toBeGreaterThanOrEqual(15) + const downloaded = await downloading + await downloaded.saveAs('out/test.mp4') + const info = await readMovieInfo('out/test.mp4') + + logger.info('info: ', info) + + expect(info.relativeDuration() || 0).toBe(0) // broken info + + // now trying to fix broken info with ffmpeg + + await modules['ffmpeg'].loadToPage() + + const downloadingFix = page.waitForEvent('download') + await page.evaluate(async () => { + const { testVideo, getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + console.log('ffmpeg loaded. converting file....') + const input = await new Blob(testVideo, { type: 'video/mp4' }).arrayBuffer() + await ffmpeg.writeFile('input.mp4', new Uint8Array(input)) + console.log('input file written, executing....') + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'input.mp4', + '-c', 'copy', + 'output-uncut.mp4' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-sseof', '-15', + '-i', 'output-uncut.mp4', + '-r', '60', + '-avoid_negative_ts', 'make_zero', + '-c', 'copy', + 'cut.mp4' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'cut.mp4', + '-t', '15', + '-c', 'copy', + 'output.mp4' + ]) + + console.log('output file written, downloading....') + const data = await ffmpeg.readFile('output.mp4') + const output = new Blob([data], { type: 'video/mp4' }) + utils.file.downloadBlob(output, 'fixed.mp4') + }) + + const downloadedFix = await downloadingFix + await downloadedFix.saveAs('out/fixed.mp4') + const infoFix = await readMovieInfo('out/fixed.mp4') + + logger.info('infoFix: ', infoFix) + logger.info('duration:', infoFix.relativeDuration()) + + expect(Math.floor(infoFix.relativeDuration())).toBe(15) + } +) + + +test( + '測試透過 Buffer 錄製 FLV 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', + async ({ room: { stream, roomid }, page, modules }) => { + + await modules['recorder'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async ({ stream, roomid }) => { + + const { createRecorder, utils } = window as any + + const recorder = createRecorder(roomid, stream, + 'buffer', + { + type: 'flv', + codec: 'avc' + } + ) + + await recorder.start() + await utils.misc.sleep(30000) + + const { chunks } = await recorder.loadChunkData() + utils.file.download('test.flv', [...chunks], 'video/x-flv') + + console.info('cleaning up stream buffer...') + await recorder.stop() + await recorder.flush() + + { + // for next use + (window as any).testVideo = chunks; + } + + return chunks.length + + }, { stream, roomid }) + + expect(length).toBeGreaterThanOrEqual(15) + const downloaded = await downloading + await downloaded.saveAs('out/test.flv') + const info = await readMovieInfo('out/test.flv') + + logger.info('info: ', info) + + expect(info.relativeDuration() || 0).toBe(0) // broken info + + // now trying to fix broken info with ffmpeg + + await modules['ffmpeg'].loadToPage() + + const downloadingFix = page.waitForEvent('download') + await page.evaluate(async () => { + const { testVideo, getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + console.log('ffmpeg loaded. converting file....') + const input = await new Blob(testVideo, { type: 'video/x-flv' }).arrayBuffer() + await ffmpeg.writeFile('input.flv', new Uint8Array(input)) + console.log('input file written, executing....') + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'input.flv', + '-c', 'copy', + 'output-uncut.flv' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-sseof', '-15', + '-i', 'output-uncut.flv', + '-r', '60', + '-c', 'copy', + '-avoid_negative_ts', 'make_zero', + 'cut.flv' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'cut.flv', + '-t', '15', + '-c', 'copy', + 'output.flv' + ]) + + console.log('output file written, downloading....') + const data = await ffmpeg.readFile('output.flv') + const output = new Blob([data], { type: 'video/x-flv' }) + utils.file.downloadBlob(output, 'fixed.flv') + }) + + const downloadedFix = await downloadingFix + await downloadedFix.saveAs('out/fixed.flv') + const infoFix = await readMovieInfo('out/fixed.flv') + + logger.info('infoFix: ', infoFix) + logger.info('duration:', infoFix.relativeDuration()) + + expect(Math.floor(infoFix.relativeDuration())).toBe(15) + } + +) + +test( + '測試透過 Capture 錄製 WEBM 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', + async ({ room: { stream, roomid }, page, modules }) => { + + await page.goto(`https://live.bilibili.com/${roomid}`, { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('video') + + await modules['recorder'].loadToPage() + await modules['utils'].loadToPage() + + // gesture to start the stream + await page.click('body') + + // for unmute + page.once('dialog', d => d.accept()) + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async ({ stream, roomid }) => { + + const { createRecorder, utils } = window as any + + // autoSwitchQuality require extension + const recorder = createRecorder(roomid, stream, 'capture', { autoSwitchQuality: false }) + + await recorder.start() + await utils.misc.sleep(30000) + + const { chunks } = await recorder.loadChunkData() + utils.file.download('test.webm', [...chunks], 'video/webm') + + console.info('cleaning up stream buffer...') + await recorder.stop() + await recorder.flush() + + { + // for next use + (window as any).testVideo = chunks; + } + + return chunks.length + + }, { stream, roomid }) + + expect(length).toBeGreaterThanOrEqual(15) + const downloaded = await downloading + await downloaded.saveAs('out/test.webm') + + // cannot find a library to read webm info, so we will use ffmpeg to fix it + // const info = await readMovieInfo('out/test.webm') + // logger.info('info: ', info) + // expect(info.relativeDuration() || 0).toBe(0) // broken info + // now trying to fix broken info with ffmpeg + + await modules['ffmpeg'].loadToPage() + + const downloadingFix = page.waitForEvent('download') + await page.evaluate(async () => { + const { testVideo, getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + console.log('ffmpeg loaded. converting file....') + const input = await new Blob(testVideo, { type: 'video/webm' }).arrayBuffer() + await ffmpeg.writeFile('input.webm', new Uint8Array(input)) + console.log('input file written, executing....') + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'input.webm', + '-c', 'copy', + 'output-uncut.mp4' + ]) + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-sseof', '-15', + '-i', 'output-uncut.mp4', + '-r', '60', '-c', 'copy', + '-avoid_negative_ts', 'make_zero', + 'output.mp4' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'output.mp4', + '-t', '15', + '-c', 'copy', + 'output.mp4' + ]) + + console.log('output file written, downloading....') + const data = await ffmpeg.readFile('output.mp4') + const output = new Blob([data], { type: 'video/mp4' }) + utils.file.downloadBlob(output, 'fixed.mp4') + }) + + const downloadedFix = await downloadingFix + await downloadedFix.saveAs('out/fixed.mp4') + const infoFix = await readMovieInfo('out/fixed.mp4') + + logger.info('infoFix: ', infoFix) + logger.info('duration:', infoFix.relativeDuration()) + + expect(Math.floor(infoFix.relativeDuration())).toBe(15) + } +) + + +test('測試 HLS 長錄製,並用 ffmpeg.wasm 修復資訊損壞 + 剪時', async ({ room: { stream, roomid }, page, modules }) => { + + // 太費時間,且與 features/recorder.spec.ts 重複 + test.skip(!!process.env.CI, 'local test only') + + // 10 mins + test.setTimeout(600000) + + await modules['recorder'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async ({ stream, roomid }) => { + + const { createRecorder, utils } = window as any + + const recorder = createRecorder(roomid, stream, + 'buffer', + { + type: 'hls', + codec: 'avc' + } + ) + + await recorder.start() + // 6 mins + await utils.misc.sleep(360000) + + const { chunks } = await recorder.loadChunkData() + utils.file.download('test.mp4', [...chunks], 'video/mp4') + + console.info('cleaning up stream buffer...') + await recorder.stop() + await recorder.flush() + + { + // for next use + (window as any).testVideo = chunks; + } + + return chunks.length + + }, { stream, roomid }) + + expect(length).toBeGreaterThan(100) + const downloaded = await downloading + await downloaded.saveAs('out/test.mp4') + const info = await readMovieInfo('out/test.mp4') + + logger.info('info: ', info) + + expect(info.relativeDuration() || 0).toBe(0) // broken info + + // now trying to fix broken info with ffmpeg + + await modules['ffmpeg'].loadToPage() + + const downloadingFix = page.waitForEvent('download') + await page.evaluate(async () => { + const { testVideo, getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + console.log('ffmpeg loaded. converting file....') + const input = await new Blob(testVideo, { type: 'video/mp4' }).arrayBuffer() + await ffmpeg.writeFile('input.mp4', new Uint8Array(input)) + console.log('input file written, executing....') + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'input.mp4', + '-c', 'copy', + 'output-uncut.mp4' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-sseof', '-300', + '-i', 'output-uncut.mp4', + '-r', '60', + '-avoid_negative_ts', 'make_zero', + '-c', 'copy', + 'cut.mp4' + ]) + + await ffmpeg.exec([ + '-fflags', '+genpts+igndts', + '-i', 'cut.mp4', + '-t', '300', + '-c', 'copy', + 'output.mp4' + ]) + + console.log('output file written, downloading....') + const data = await ffmpeg.readFile('output.mp4') + const output = new Blob([data], { type: 'video/mp4' }) + utils.file.downloadBlob(output, 'fixed.mp4') + }) + + const downloadedFix = await downloadingFix + await downloadedFix.saveAs('out/fixed.mp4') + const infoFix = await readMovieInfo('out/fixed.mp4') + + logger.info('infoFix: ', infoFix) + logger.info('duration:', infoFix.relativeDuration()) + + // fixed info with gap 15-18s + expect(Math.round(infoFix.relativeDuration())).toBe(300) + +}) \ No newline at end of file diff --git a/tests/modules/recorder.js b/tests/modules/recorder.js new file mode 100644 index 00000000..36757279 --- /dev/null +++ b/tests/modules/recorder.js @@ -0,0 +1,3 @@ +import createRecorder from '~features/recorder/recorders' + +window.createRecorder = createRecorder \ No newline at end of file diff --git a/tests/units/buffer.spec.ts b/tests/units/buffer.spec.ts index ec8279b1..4e60bb0a 100644 --- a/tests/units/buffer.spec.ts +++ b/tests/units/buffer.spec.ts @@ -145,4 +145,58 @@ test('測試終止 FLV 推流后有否成功關閉數據流', async ({ modules, }, stream) expect(before).toBe(after) +}) + +test('測試 HLS 長錄製有否 flush buffer', async ({ context, modules, page, room: { stream } }) => { + + // 5 mins + test.setTimeout(300000) + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const logs: string[] = [] + context.on('console', (msg) => { + logs.push(msg.text()) + }) + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async (stream) => { + + const { player, utils } = window as any + + const chunks = [] + + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + const blob = new Blob([buffer], { type: 'application/octet-stream' }) + chunks.push(blob) + console.info('total chunks size: ', chunks.length, buffer.byteLength) + }, { + type: 'hls', + codec: 'avc' + }) + + await utils.misc.sleep(60000) // 測試錄製 60 秒 + console.info('cleaning up stream buffer...') + p.stopAndDestroy() + + utils.file.download('test.mp4', [...chunks], 'video/mp4') + + return chunks.length + + }, stream) + + expect(length).toBeGreaterThan(0) // 確保有錄製到東西 + const downloaded = await downloading + await downloaded.saveAs('out/test.mp4') + + const info = await readMovieInfo('out/test.mp4') // 確保影片是能被正常解析的 + logger.info('info: ', info) + + // 檢查是否有 flush buffer + const flushed = logs.filter(l => l.includes('buffer flushing') || l.includes('flushing buffer...')) + + logger.info('flush messages: ', flushed) + + expect(flushed.length).toBeGreaterThan(0) }) \ No newline at end of file diff --git a/tests/units/capture.spec.ts b/tests/units/capture.spec.ts new file mode 100644 index 00000000..314884f4 --- /dev/null +++ b/tests/units/capture.spec.ts @@ -0,0 +1,183 @@ +import { expect } from "@playwright/test"; +import { test } from "@tests/fixtures/component"; +import logger from "@tests/helpers/logger"; + +test.slow() + +test('測試畫質轉換', async ({ page, room: { roomid, stream }, modules }) => { + + await page.goto(`https://live.bilibili.com/${roomid}`, { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('video') + await modules['utils'].loadToPage() + + const checker = await page.evaluate(async (stream) => { + + const self = window as any + + console.debug('waiting for player...') + while (!self.$P2PLivePlayer && !self.livePlayer) { + console.debug('check: ', self.$P2PLivePlayer, self.livePlayer) + await self.utils.misc.sleep(1000) + } + + const player = self.$P2PLivePlayer || self.livePlayer + + const qualities = new Set([...stream.filter(s => s.codec === 'avc').map(s => s.quality)]) + console.debug('available qualities: ', [...qualities]) + + async function switchQuality(quality) { + const video = document.querySelector('video') + console.debug('switching quality...') + player.switchQuality(quality) + let counts = 0 + while (document.getElementById(video.id)) { + await self.utils.misc.sleep(150) + if (counts++ === 3000 / 150) { + break + } + } + } + + const checker = {} + + for (const quality of qualities) { + console.debug('switching to quality: ', quality) + await switchQuality(`${quality}`) + const actualChanged = player.getPlayerInfo().quality + console.info('quality is now: ', actualChanged) + checker[quality] = actualChanged + } + + return checker + + }, stream) + + for (const key in checker) { + expect(checker[key]).toBe(key) + } + +}) + + +test('測試 MediaRecorder + captureStream', async ({ page, room: { roomid }, modules }) => { + + await page.goto(`https://live.bilibili.com/${roomid}`, { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('video') + + await modules['utils'].loadToPage() + + const download = page.waitForEvent('download') + const chunks = await page.evaluate(async () => { + + const { utils } = window as any + + const video = document.querySelector('video') + video.muted = false // unmute is required for captureStream + + const stream = video.captureStream() + const recorder = new MediaRecorder(stream) + + const chunks = [] + + recorder.ondataavailable = (e) => { + console.debug('data available: ', e.data.size) + chunks.push(e.data) + } + + recorder.onstop = () => { + const blob = new Blob(chunks, { type: 'video/webm' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'test.webm' + a.click() + } + + recorder.start(1000) + await utils.misc.sleep(10000) + + recorder.stop() + + await utils.misc.sleep(3000) + + return { + size: chunks.map(c => c.size).reduce((a, b) => a + b), + length: chunks.length + } + + }) + + logger.info('chunks: ', chunks) + + // length should be greater than 5 + expect(chunks.length).toBeGreaterThan(5) + // size should be greater than 1000000 + expect(chunks.size).toBeGreaterThan(1000000) + + const downloaded = await download + + expect(downloaded.suggestedFilename()).toBe('test.webm') + +}) + +test.fail('測試 MediaRecorder 在靜音時的錄製', async ({ page, room: { roomid }, modules }) => { + + await page.goto(`https://live.bilibili.com/${roomid}`, { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('video') + + await modules['utils'].loadToPage() + + const download = page.waitForEvent('download') + const chunks = await page.evaluate(async () => { + + const { utils } = window as any + + const video = document.querySelector('video') + video.muted = true + + const stream = video.captureStream() + const recorder = new MediaRecorder(stream) + + const chunks = [] + + recorder.ondataavailable = (e) => { + console.debug('data available: ', e.data.size) + chunks.push(e.data) + } + + recorder.onstop = () => { + const blob = new Blob(chunks, { type: 'video/webm' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'test.webm' + a.click() + } + + recorder.start(1000) + await utils.misc.sleep(10000) + + recorder.stop() + + await utils.misc.sleep(3000) + + return { + size: chunks.map(c => c.size).reduce((a, b) => a + b), + length: chunks.length + } + + }) + + logger.info('chunks: ', chunks) + + // length should be greater than 5 + expect(chunks.length).toBeGreaterThan(5) + // size should be greater than 1000000 + expect(chunks.size).toBeGreaterThan(1000000) + + const downloaded = await download + + expect(downloaded.suggestedFilename()).toBe('test.webm') + +}) + diff --git a/tests/utils/misc.ts b/tests/utils/misc.ts index d289e63f..1cc1f1c6 100644 --- a/tests/utils/misc.ts +++ b/tests/utils/misc.ts @@ -117,4 +117,22 @@ export function deferAsync(run: () => Promise): { [Symbol.asyncDispose]: ( return { [Symbol.asyncDispose]: run } +} + + +/** + * Shuffles the items in an array. + * + * @param items - The array of items to be shuffled. + * @returns The shuffled array. + */ +export function shuffle(items: T[]): T[] { + const shuffled = [...items] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + const temp = shuffled[i] + shuffled[i] = shuffled[j] + shuffled[j] = temp + } + return shuffled } \ No newline at end of file diff --git a/tests/utils/playwright.ts b/tests/utils/playwright.ts index d86d1dc5..d96b0fe2 100644 --- a/tests/utils/playwright.ts +++ b/tests/utils/playwright.ts @@ -90,4 +90,14 @@ export function testFeatureRoomList(feature: string, expect: Expect, lo await expect(locator).toBeVisible() } +} + + +export async function selectOption(selector: Locator, option: string | Locator){ + await selector.locator('div > div').nth(0).click() + if(typeof option === 'string'){ + await selector.getByText(option).click() + } else { + await option.click() + } } \ No newline at end of file From a69c10d5f1d701a18aefaa6a498ed24c699f29bb Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sat, 13 Apr 2024 17:04:43 +0800 Subject: [PATCH 08/10] revised e2e test cases based on enhancements --- tests/content.spec.ts | 32 ++++++++++++++++++++++++++++- tests/features/recorder.spec.ts | 15 ++++++++++++-- tests/integrations/recorder.spec.ts | 5 ++++- tests/pages/options.spec.ts | 11 ++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/tests/content.spec.ts b/tests/content.spec.ts index 3db45ddb..fca60d68 100644 --- a/tests/content.spec.ts +++ b/tests/content.spec.ts @@ -195,6 +195,7 @@ test('測試重新启动按鈕', async ({ content, optionPageUrl, context }) => test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content }) => { + logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() @@ -202,6 +203,7 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content await settingsPage.getByText('保存设定').click() await settingsPage.close() + logger.info('正在測試弹出直播视窗按鈕...') await content.getByText('功能菜单').click() await content.locator('#bjf-main-menu').waitFor({ state: 'visible' }) @@ -210,6 +212,7 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content const monitor = await popup await monitor.waitForTimeout(2000) + logger.info('正在測試元素是否存在...') // video container await expect(monitor.locator('div#__plasmo > div#bjf-danmaku-container')).toBeVisible() @@ -236,6 +239,7 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content 'media-chrome-button#reload-btn' // custom button ] + logger.info('正在測試媒體控制按鈕是否存在...') await monitor.locator('media-control-bar').hover() for (const button of buttons) { const locator = monitor.locator(button) @@ -243,6 +247,7 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content } // Test custom buttons + logger.info('正在測試媒體自定義按鈕...') // danmaku button await monitor.locator('media-control-bar').hover() @@ -257,11 +262,36 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content await monitor.locator('#reload-btn').click() await reload + logger.info('正在測試直播畫面有否正常播放....') + await monitor.waitForSelector('video', { state: 'visible' }) + + const beforeCurrentTime = await monitor.evaluate(() => { + return document.querySelector('video').currentTime + }) + await monitor.waitForTimeout(3000) + const afterCurrentTime = await monitor.evaluate(() => { + return document.querySelector('video').currentTime + }) + expect(afterCurrentTime).toBeGreaterThan(beforeCurrentTime) + + const beforeBufferEnd = await monitor.evaluate(async () => { + const video = document.querySelector('video') + return video.buffered.end(video.buffered.length - 1) + }) + await monitor.waitForTimeout(3000) + const afterBufferEnd = await monitor.evaluate(() => { + const video = document.querySelector('video') + return video.buffered.end(video.buffered.length - 1) + }) + expect(afterBufferEnd).toBeGreaterThan(beforeBufferEnd) + await monitor.close() }) -test('測試大海報房間下返回非海报界面按鈕', async ({ context, themeRoom: room }) => { +test('測試大海報房間下返回非海报界面按鈕', async ({ context, room, isThemeRoom }) => { + + test.skip(!isThemeRoom, '此測試只適用於大海報房間') const content = await room.getContentLocator() await content.locator('body').scrollIntoViewIfNeeded() diff --git a/tests/features/recorder.spec.ts b/tests/features/recorder.spec.ts index 67a36d71..b1e3c4e4 100644 --- a/tests/features/recorder.spec.ts +++ b/tests/features/recorder.spec.ts @@ -263,6 +263,7 @@ test('測試熱鍵錄製', async ({ page, optionPageUrl, context, content }) => await page.waitForTimeout(30000) const downloading = page.waitForEvent('download') + await content.locator('body').click() // gesture the iframe (if theme room) await page.keyboard.press('Control+Shift+R') const downloaded = await downloading @@ -303,7 +304,8 @@ test('測試熱鍵截圖', async ({ page, content, context, optionPageUrl }) => await content.getByTestId('screenshot-button').waitFor({ state: 'visible' }) const download = page.waitForEvent('download') - await page.locator('body').press('Control+Shift+V') + await content.locator('body').click() // gesture the iframe (if theme room) + await page.keyboard.press('Control+Shift+V') await expect(content.getByText('截图成功并已保存')).toBeVisible() const downloaded = await download @@ -737,7 +739,16 @@ test('測試 WEBM 錄製 - 更換視頻源時的處理', async ({ content, page, expect(info.relativeDuration()).toBeGreaterThanOrEqual(5) }) -test('測試 WEBM 錄製 - 不解除靜音的處理', async ({ content, page, api, room, optionPageUrl, context }) => { +test('測試 WEBM 錄製 - 不解除靜音的處理', async ({ content, page, optionPageUrl, context }) => { + + // first, we need to mute the video + await content.waitForSelector('video') + await content.evaluate(() => { + const video = document.querySelector('video') + if (video?.muted === false) { + video.muted = true + } + }) page.on('dialog', d => { if (d.message().includes('解除静音')) { diff --git a/tests/integrations/recorder.spec.ts b/tests/integrations/recorder.spec.ts index 5ba9bccd..586a7878 100644 --- a/tests/integrations/recorder.spec.ts +++ b/tests/integrations/recorder.spec.ts @@ -310,7 +310,10 @@ test( logger.info('infoFix: ', infoFix) logger.info('duration:', infoFix.relativeDuration()) - expect(Math.floor(infoFix.relativeDuration())).toBe(15) + // not sure why, but seems webm cut duration is longer than expected! + // gap 15-20s + expect(Math.floor(infoFix.relativeDuration())).toBeGreaterThanOrEqual(15) + expect(Math.floor(infoFix.relativeDuration())).toBeLessThanOrEqual(20) } ) diff --git a/tests/pages/options.spec.ts b/tests/pages/options.spec.ts index dbcc8f42..56f28e52 100644 --- a/tests/pages/options.spec.ts +++ b/tests/pages/options.spec.ts @@ -478,6 +478,17 @@ test('測試导航', async ({ page, serviceWorker }) => { } }) +test('測試點擊使用指南', async ({ context, page }) => { + + await page.getByText('功能设定').click() + + const tutorial = context.waitForEvent('page') + await page.getByText('使用指南').click() + const tutorialPage = await tutorial + + expect(tutorialPage.url()).toBe('https://eric2788.github.io/bilibili-vup-stream-enhancer/tutorials/') + +}) async function compareTable(table: Locator, data: string[], index: number = 0): Promise { const rows = await table.locator('tbody tr').all() From 021274c83dd8e1dea83e63e7476e8f57ec674149 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sat, 13 Apr 2024 19:32:22 +0800 Subject: [PATCH 09/10] updated docs and fixed typo --- README.md | 6 ++++-- docs/background.md | 1 + docs/settings.md | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0e6a2ed9..8cf77590 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,12 @@ ## ➵ 使用方式 1. [下载](#-下载)本扩展。 -2. 点击扩展图标进入设定页面,并根据你的偏好进入设定。完成后,然后按下保存设定。 +2. 点击扩展图标进入设定页面,并根据你的偏好进行设定。完成后,按下保存设定。 3. 进入B站任一直播间即可开始使用。 -详情可参阅 [使用指南](https://eric2788.github.io/bilibili-vup-stream-enhancer/tutorials) +> 本扩展的所有功能基本上可以到设定页面自行探索和试用,便不再加篇章一一赘述。 +> +> 不过考虑到有些功能可能比较难以察觉,故写了篇 [使用指南](https://eric2788.github.io/bilibili-vup-stream-enhancer/tutorials) (仅限难以察觉的功能)。 ## ➵ 贡献 diff --git a/docs/background.md b/docs/background.md index 7a7e3672..6cd97498 100644 --- a/docs/background.md +++ b/docs/background.md @@ -126,6 +126,7 @@ const menus = [ - 把内容脚本的弹幕数据发送到扩展页面 - 把内容脚本的同传字幕发送到扩展页面 - 扩展页面的重启指令发送到内容脚本 +- 发送预编译的视频数据到扩展页面 例子如下: ```ts diff --git a/docs/settings.md b/docs/settings.md index 5c2f5892..244fce11 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -156,7 +156,7 @@ function App(): JSX.Element { - 使用 [`getSettingStorage`](/src/utils/storage.ts) 函数: ```ts -const helloWorldSettings = getSettingStorage('settings.helloWorld') +const helloWorldSettings = await getSettingStorage('settings.helloWorld') ``` > 此方式返回的数据本身包含设定结构,因此无需手动标注类型。 From 32e70a6a4d1f21fd395a2248667df50fdb217870 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sun, 14 Apr 2024 00:58:40 +0800 Subject: [PATCH 10/10] changed r2 upload task to concurrent --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index a6fa71ab..21daf0e2 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -166,7 +166,7 @@ jobs: if-no-files-found: ignore - name: Upload Results To R2 if: failure() && steps.test.conclusion == 'failure' - uses: eric2788/r2-upload-action@master + uses: eric2788/r2-upload-action@concurrent id: upload continue-on-error: true with: