feat(web): enhance configurable input regions

Make input-region calibration work across MJPEG and H.264 video modes, including robust four-sided black-border detection and initial-selection edge snapping. Persist multiple reusable manual regions, keep manual and original-resolution choices mutually exclusive, migrate legacy settings, and localize the new controls for every supported language.
This commit is contained in:
watermeko
2026-08-24 12:06:38 +00:00
committed by Guoguo
parent 9e6d280826
commit 2ba45a2147
39 changed files with 536 additions and 179 deletions

View File

@@ -163,6 +163,17 @@ type InputRegion struct {
Height int `json:"height"` Height int `json:"height"`
Resolutions []OriginalResolution `json:"resolutions,omitempty"` Resolutions []OriginalResolution `json:"resolutions,omitempty"`
SelectedResolution string `json:"selectedResolution"` SelectedResolution string `json:"selectedResolution"`
Regions []ManualRegion `json:"regions,omitempty"`
SelectedRegion string `json:"selectedRegion"`
}
type ManualRegion struct {
FrameWidth int `json:"frameWidth"`
FrameHeight int `json:"frameHeight"`
Left int `json:"left"`
Top int `json:"top"`
Width int `json:"width"`
Height int `json:"height"`
} }
type OriginalResolution struct { type OriginalResolution struct {
@@ -180,6 +191,8 @@ type SetInputRegionReq struct {
Height *int `json:"height,omitempty"` Height *int `json:"height,omitempty"`
Resolutions *[]OriginalResolution `json:"resolutions,omitempty"` Resolutions *[]OriginalResolution `json:"resolutions,omitempty"`
SelectedResolution *string `json:"selectedResolution,omitempty"` SelectedResolution *string `json:"selectedResolution,omitempty"`
Regions *[]ManualRegion `json:"regions,omitempty"`
SelectedRegion *string `json:"selectedRegion,omitempty"`
} }
type GetInputRegionRsp struct { type GetInputRegionRsp struct {

View File

@@ -99,7 +99,20 @@ func (s *Service) SetInputRegion(c *gin.Context) {
if req.SelectedResolution != nil { if req.SelectedResolution != nil {
region.SelectedResolution = *req.SelectedResolution region.SelectedResolution = *req.SelectedResolution
} }
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil { if req.Regions != nil {
if err := validateManualRegions(*req.Regions); err != nil {
rsp.ErrRsp(c, -1, "invalid manual regions")
return
}
region.Regions = *req.Regions
if len(region.Regions) == 0 {
clearLegacyManualRegion(region)
}
}
if req.SelectedRegion != nil {
region.SelectedRegion = *req.SelectedRegion
}
if err := validateSelections(*region); err != nil {
rsp.ErrRsp(c, -1, "invalid selected resolution") rsp.ErrRsp(c, -1, "invalid selected resolution")
return return
} }
@@ -127,6 +140,8 @@ func (s *Service) SetInputRegion(c *gin.Context) {
if previous != nil { if previous != nil {
region.Resolutions = previous.Resolutions region.Resolutions = previous.Resolutions
region.SelectedResolution = previous.SelectedResolution region.SelectedResolution = previous.SelectedResolution
region.Regions = previous.Regions
region.SelectedRegion = previous.SelectedRegion
} }
if req.Resolutions != nil { if req.Resolutions != nil {
if err := validateOriginalResolutions(*req.Resolutions); err != nil { if err := validateOriginalResolutions(*req.Resolutions); err != nil {
@@ -138,7 +153,20 @@ func (s *Service) SetInputRegion(c *gin.Context) {
if req.SelectedResolution != nil { if req.SelectedResolution != nil {
region.SelectedResolution = *req.SelectedResolution region.SelectedResolution = *req.SelectedResolution
} }
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil { if req.Regions != nil {
if err := validateManualRegions(*req.Regions); err != nil {
rsp.ErrRsp(c, -1, "invalid manual regions")
return
}
region.Regions = *req.Regions
if len(region.Regions) == 0 {
clearLegacyManualRegion(&region)
}
}
if req.SelectedRegion != nil {
region.SelectedRegion = *req.SelectedRegion
}
if err := validateSelections(region); err != nil {
rsp.ErrRsp(c, -1, "invalid selected resolution") rsp.ErrRsp(c, -1, "invalid selected resolution")
return return
} }
@@ -176,10 +204,68 @@ func validateSelectedResolution(selected string, resolutions []proto.OriginalRes
return errors.New("selected resolution not found") return errors.New("selected resolution not found")
} }
func validateManualRegions(regions []proto.ManualRegion) error {
seen := make(map[string]struct{}, len(regions))
for _, region := range regions {
if err := validateManualRegion(region); err != nil {
return err
}
key := manualRegionKey(region)
if _, ok := seen[key]; ok {
return errors.New("duplicate manual region")
}
seen[key] = struct{}{}
}
return nil
}
func validateManualRegion(region proto.ManualRegion) error {
return validateInputRegion(proto.InputRegion{
Mode: "manual",
FrameWidth: region.FrameWidth,
FrameHeight: region.FrameHeight,
Left: region.Left,
Top: region.Top,
Width: region.Width,
Height: region.Height,
})
}
func validateSelections(region proto.InputRegion) error {
if region.SelectedResolution != "" && region.SelectedRegion != "" {
return errors.New("only one input region source may be selected")
}
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
return err
}
if region.SelectedRegion == "" {
return nil
}
for _, item := range region.Regions {
if region.SelectedRegion == manualRegionKey(item) {
return nil
}
}
return errors.New("selected manual region not found")
}
func resolutionKey(resolution proto.OriginalResolution) string { func resolutionKey(resolution proto.OriginalResolution) string {
return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height) return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height)
} }
func manualRegionKey(region proto.ManualRegion) string {
return fmt.Sprintf("%dx%d", region.Width, region.Height)
}
func clearLegacyManualRegion(region *proto.InputRegion) {
region.FrameWidth = 0
region.FrameHeight = 0
region.Left = 0
region.Top = 0
region.Width = 0
region.Height = 0
}
func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) { func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) {
if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil || if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil ||
req.Top == nil || req.Width == nil || req.Height == nil { req.Top == nil || req.Width == nil || req.Height == nil {
@@ -236,9 +322,29 @@ func readInputRegion(path string) (*proto.InputRegion, error) {
if region.Mode == "" { if region.Mode == "" {
region.Mode = "manual" region.Mode = "manual"
} }
if len(region.Regions) == 0 && region.FrameWidth > 0 && region.FrameHeight > 0 {
manual := proto.ManualRegion{
FrameWidth: region.FrameWidth,
FrameHeight: region.FrameHeight,
Left: region.Left,
Top: region.Top,
Width: region.Width,
Height: region.Height,
}
region.Regions = []proto.ManualRegion{manual}
if region.SelectedResolution == "" {
region.SelectedRegion = manualRegionKey(manual)
}
}
if err := validateInputRegion(*region); err != nil { if err := validateInputRegion(*region); err != nil {
return nil, nil return nil, nil
} }
if err := validateManualRegions(region.Regions); err != nil {
return nil, nil
}
if err := validateSelections(*region); err != nil {
return nil, nil
}
return region, nil return region, nil
} }

View File

@@ -48,11 +48,17 @@ export function setInputRegion(region: InputRegion) {
return http.post('/api/vm/input-region', { mode: 'manual', ...region }); return http.post('/api/vm/input-region', { mode: 'manual', ...region });
} }
export function setInputRegionConfig(region: InputRegion, selectedResolution: string) { export function setInputRegionConfig(
region: InputRegion,
regions: InputRegion[],
selectedRegion: string
) {
return http.post('/api/vm/input-region', { return http.post('/api/vm/input-region', {
mode: 'manual', mode: 'manual',
...region, ...region,
selectedResolution regions,
selectedRegion,
selectedResolution: ''
}); });
} }
@@ -66,17 +72,36 @@ export function setOriginalResolutions(mode: ControlRegionMode, resolutions: Ori
export function setOriginalResolutionConfig( export function setOriginalResolutionConfig(
resolutions: OriginalResolution[], resolutions: OriginalResolution[],
selectedResolution: string selectedResolution: string,
selectedRegion: string
) { ) {
return http.post('/api/vm/input-region', { return http.post('/api/vm/input-region', {
mode: 'manual', mode: 'manual',
resolutions, resolutions,
selectedResolution selectedResolution,
selectedRegion
}); });
} }
export function setSelectedOriginalResolution(selectedResolution: string) { export function setSelectedOriginalResolution(selectedResolution: string) {
return http.post('/api/vm/input-region', { mode: 'manual', selectedResolution }); return http.post('/api/vm/input-region', {
mode: 'manual',
selectedResolution,
selectedRegion: ''
});
}
export function setManualRegions(
regions: InputRegion[],
selectedRegion: string,
selectedResolution = ''
) {
return http.post('/api/vm/input-region', {
mode: 'manual',
regions,
selectedRegion,
selectedResolution
});
} }
// get memory limit // get memory limit

View File

@@ -73,9 +73,10 @@ const ca = {
autoWarning: autoWarning:
"El calibratge pot fallar quan l'aplicació de l'usuari tingui un fons completament negre.", "El calibratge pot fallar quan l'aplicació de l'usuari tingui un fons completament negre.",
manual: 'Manual', manual: 'Manual',
selectedResolution: "Resolució de l'àrea seleccionada",
unused: 'No utilitzada',
originalResolution: 'Resolució original', originalResolution: 'Resolució original',
selectResolution: 'Selecciona la resolució original', selectResolution: 'Selecciona la resolució original',
useSelectedArea: "Utilitza la resolució de l'àrea seleccionada",
addResolution: 'Afegeix una resolució personalitzada', addResolution: 'Afegeix una resolució personalitzada',
add: 'Afegeix', add: 'Afegeix',
duplicateResolution: 'Aquesta resolució ja existeix.', duplicateResolution: 'Aquesta resolució ja existeix.',

View File

@@ -73,9 +73,10 @@ const cz = {
auto: 'Automaticky', auto: 'Automaticky',
autoWarning: 'Kalibrace může selhat, pokud má uživatelská aplikace zcela černé pozadí.', autoWarning: 'Kalibrace může selhat, pokud má uživatelská aplikace zcela černé pozadí.',
manual: 'Ručně', manual: 'Ručně',
selectedResolution: 'Rozlišení vybrané oblasti',
unused: 'Nepoužito',
originalResolution: 'Původní rozlišení', originalResolution: 'Původní rozlišení',
selectResolution: 'Vyberte původní rozlišení', selectResolution: 'Vyberte původní rozlišení',
useSelectedArea: 'Použít rozlišení vybrané oblasti',
addResolution: 'Přidat vlastní rozlišení', addResolution: 'Přidat vlastní rozlišení',
add: 'Přidat', add: 'Přidat',
duplicateResolution: 'Toto rozlišení již existuje.', duplicateResolution: 'Toto rozlišení již existuje.',

View File

@@ -73,9 +73,10 @@ const da = {
autoWarning: autoWarning:
'Kalibreringen kan mislykkes, hvis brugerprogrammet har en helt sort baggrund.', 'Kalibreringen kan mislykkes, hvis brugerprogrammet har en helt sort baggrund.',
manual: 'Manuel', manual: 'Manuel',
selectedResolution: 'Valgt områdeopløsning',
unused: 'Ikke i brug',
originalResolution: 'Oprindelig opløsning', originalResolution: 'Oprindelig opløsning',
selectResolution: 'Vælg oprindelig opløsning', selectResolution: 'Vælg oprindelig opløsning',
useSelectedArea: 'Brug det valgte områdes opløsning',
addResolution: 'Tilføj brugerdefineret opløsning', addResolution: 'Tilføj brugerdefineret opløsning',
add: 'Tilføj', add: 'Tilføj',
duplicateResolution: 'Denne opløsning findes allerede.', duplicateResolution: 'Denne opløsning findes allerede.',

View File

@@ -76,9 +76,10 @@ const de = {
autoWarning: autoWarning:
'Die Kalibrierung kann fehlschlagen, wenn die Benutzeranwendung einen vollständig schwarzen Hintergrund hat.', 'Die Kalibrierung kann fehlschlagen, wenn die Benutzeranwendung einen vollständig schwarzen Hintergrund hat.',
manual: 'Manuell', manual: 'Manuell',
selectedResolution: 'Auflösung des ausgewählten Bereichs',
unused: 'Nicht verwendet',
originalResolution: 'Originalauflösung', originalResolution: 'Originalauflösung',
selectResolution: 'Originalauflösung auswählen', selectResolution: 'Originalauflösung auswählen',
useSelectedArea: 'Auflösung des ausgewählten Bereichs verwenden',
addResolution: 'Benutzerdefinierte Auflösung hinzufügen', addResolution: 'Benutzerdefinierte Auflösung hinzufügen',
add: 'Hinzufügen', add: 'Hinzufügen',
duplicateResolution: 'Diese Auflösung ist bereits vorhanden.', duplicateResolution: 'Diese Auflösung ist bereits vorhanden.',

View File

@@ -74,9 +74,10 @@ const en = {
auto: 'Auto', auto: 'Auto',
autoWarning: 'Calibration may fail when the user application has a pure black background.', autoWarning: 'Calibration may fail when the user application has a pure black background.',
manual: 'Manual', manual: 'Manual',
selectedResolution: 'Selected Area Resolution',
unused: 'Not used',
originalResolution: 'Original Resolution', originalResolution: 'Original Resolution',
selectResolution: 'Select original resolution', selectResolution: 'Select original resolution',
useSelectedArea: 'Use selected area resolution',
addResolution: 'Add custom resolution', addResolution: 'Add custom resolution',
add: 'Add', add: 'Add',
duplicateResolution: 'This resolution already exists.', duplicateResolution: 'This resolution already exists.',

View File

@@ -74,9 +74,10 @@ const es = {
autoWarning: autoWarning:
'La calibración puede fallar si la aplicación del usuario tiene un fondo completamente negro.', 'La calibración puede fallar si la aplicación del usuario tiene un fondo completamente negro.',
manual: 'Manual', manual: 'Manual',
selectedResolution: 'Resolución del área seleccionada',
unused: 'Sin usar',
originalResolution: 'Resolución original', originalResolution: 'Resolución original',
selectResolution: 'Seleccionar resolución original', selectResolution: 'Seleccionar resolución original',
useSelectedArea: 'Usar la resolución del área seleccionada',
addResolution: 'Añadir resolución personalizada', addResolution: 'Añadir resolución personalizada',
add: 'Añadir', add: 'Añadir',
duplicateResolution: 'Esta resolución ya existe.', duplicateResolution: 'Esta resolución ya existe.',

View File

@@ -76,9 +76,10 @@ const fr = {
autoWarning: autoWarning:
"L'étalonnage peut échouer si l'application utilisateur présente un arrière-plan entièrement noir.", "L'étalonnage peut échouer si l'application utilisateur présente un arrière-plan entièrement noir.",
manual: 'Manuel', manual: 'Manuel',
selectedResolution: 'Résolution de la zone sélectionnée',
unused: 'Non utilisée',
originalResolution: "Résolution d'origine", originalResolution: "Résolution d'origine",
selectResolution: "Sélectionner la résolution d'origine", selectResolution: "Sélectionner la résolution d'origine",
useSelectedArea: 'Utiliser la résolution de la zone sélectionnée',
addResolution: 'Ajouter une résolution personnalisée', addResolution: 'Ajouter une résolution personnalisée',
add: 'Ajouter', add: 'Ajouter',
duplicateResolution: 'Cette résolution existe déjà.', duplicateResolution: 'Cette résolution existe déjà.',

View File

@@ -75,9 +75,10 @@ const hu = {
autoWarning: autoWarning:
'A kalibrálás sikertelen lehet, ha a felhasználói alkalmazás háttere teljesen fekete.', 'A kalibrálás sikertelen lehet, ha a felhasználói alkalmazás háttere teljesen fekete.',
manual: 'Kézi', manual: 'Kézi',
selectedResolution: 'Kijelölt terület felbontása',
unused: 'Nincs használatban',
originalResolution: 'Eredeti felbontás', originalResolution: 'Eredeti felbontás',
selectResolution: 'Válassza ki az eredeti felbontást', selectResolution: 'Válassza ki az eredeti felbontást',
useSelectedArea: 'A kijelölt terület felbontásának használata',
addResolution: 'Egyéni felbontás hozzáadása', addResolution: 'Egyéni felbontás hozzáadása',
add: 'Hozzáadás', add: 'Hozzáadás',
duplicateResolution: 'Ez a felbontás már létezik.', duplicateResolution: 'Ez a felbontás már létezik.',

View File

@@ -73,9 +73,10 @@ const id = {
autoWarning: autoWarning:
'Kalibrasi mungkin gagal jika aplikasi pengguna menggunakan latar belakang hitam pekat.', 'Kalibrasi mungkin gagal jika aplikasi pengguna menggunakan latar belakang hitam pekat.',
manual: 'Manual', manual: 'Manual',
selectedResolution: 'Resolusi Area Terpilih',
unused: 'Tidak digunakan',
originalResolution: 'Resolusi Asli', originalResolution: 'Resolusi Asli',
selectResolution: 'Pilih resolusi asli', selectResolution: 'Pilih resolusi asli',
useSelectedArea: 'Gunakan resolusi area yang dipilih',
addResolution: 'Tambahkan resolusi khusus', addResolution: 'Tambahkan resolusi khusus',
add: 'Tambah', add: 'Tambah',
duplicateResolution: 'Resolusi ini sudah ada.', duplicateResolution: 'Resolusi ini sudah ada.',

View File

@@ -75,9 +75,10 @@ const it = {
autoWarning: autoWarning:
"La calibrazione potrebbe non riuscire se l'applicazione utente ha uno sfondo completamente nero.", "La calibrazione potrebbe non riuscire se l'applicazione utente ha uno sfondo completamente nero.",
manual: 'Manuale', manual: 'Manuale',
selectedResolution: 'Risoluzione area selezionata',
unused: 'Non utilizzata',
originalResolution: 'Risoluzione originale', originalResolution: 'Risoluzione originale',
selectResolution: 'Seleziona la risoluzione originale', selectResolution: 'Seleziona la risoluzione originale',
useSelectedArea: "Usa la risoluzione dell'area selezionata",
addResolution: 'Aggiungi una risoluzione personalizzata', addResolution: 'Aggiungi una risoluzione personalizzata',
add: 'Aggiungi', add: 'Aggiungi',
duplicateResolution: 'Questa risoluzione esiste già.', duplicateResolution: 'Questa risoluzione esiste già.',

View File

@@ -73,9 +73,10 @@ const ja = {
autoWarning: autoWarning:
'ユーザーアプリケーションの背景が完全な黒の場合、補正に失敗することがあります。', 'ユーザーアプリケーションの背景が完全な黒の場合、補正に失敗することがあります。',
manual: '手動', manual: '手動',
selectedResolution: '選択領域の解像度',
unused: '未使用',
originalResolution: '元の解像度', originalResolution: '元の解像度',
selectResolution: '元の解像度を選択', selectResolution: '元の解像度を選択',
useSelectedArea: '選択領域の解像度を使用',
addResolution: 'カスタム解像度を追加', addResolution: 'カスタム解像度を追加',
add: '追加', add: '追加',
duplicateResolution: 'この解像度はすでに存在します。', duplicateResolution: 'この解像度はすでに存在します。',

View File

@@ -71,9 +71,10 @@ const ko = {
auto: '자동', auto: '자동',
autoWarning: '사용자 애플리케이션의 배경이 완전히 검은색이면 보정에 실패할 수 있습니다.', autoWarning: '사용자 애플리케이션의 배경이 완전히 검은색이면 보정에 실패할 수 있습니다.',
manual: '수동', manual: '수동',
selectedResolution: '선택 영역 해상도',
unused: '사용 안 함',
originalResolution: '원본 해상도', originalResolution: '원본 해상도',
selectResolution: '원본 해상도 선택', selectResolution: '원본 해상도 선택',
useSelectedArea: '선택한 영역의 해상도 사용',
addResolution: '사용자 지정 해상도 추가', addResolution: '사용자 지정 해상도 추가',
add: '추가', add: '추가',
duplicateResolution: '이미 존재하는 해상도입니다.', duplicateResolution: '이미 존재하는 해상도입니다.',

View File

@@ -74,9 +74,10 @@ const nb = {
autoWarning: autoWarning:
'Kalibreringen kan mislykkes hvis brukerprogrammet har en helt svart bakgrunn.', 'Kalibreringen kan mislykkes hvis brukerprogrammet har en helt svart bakgrunn.',
manual: 'Manuell', manual: 'Manuell',
selectedResolution: 'Oppløsning for valgt område',
unused: 'Ikke i bruk',
originalResolution: 'Opprinnelig oppløsning', originalResolution: 'Opprinnelig oppløsning',
selectResolution: 'Velg opprinnelig oppløsning', selectResolution: 'Velg opprinnelig oppløsning',
useSelectedArea: 'Bruk oppløsningen til det valgte området',
addResolution: 'Legg til egendefinert oppløsning', addResolution: 'Legg til egendefinert oppløsning',
add: 'Legg til', add: 'Legg til',
duplicateResolution: 'Denne oppløsningen finnes allerede.', duplicateResolution: 'Denne oppløsningen finnes allerede.',

View File

@@ -75,9 +75,10 @@ const nl = {
autoWarning: autoWarning:
'De kalibratie kan mislukken als de gebruikerstoepassing een volledig zwarte achtergrond heeft.', 'De kalibratie kan mislukken als de gebruikerstoepassing een volledig zwarte achtergrond heeft.',
manual: 'Handmatig', manual: 'Handmatig',
selectedResolution: 'Resolutie van geselecteerd gebied',
unused: 'Niet gebruikt',
originalResolution: 'Oorspronkelijke resolutie', originalResolution: 'Oorspronkelijke resolutie',
selectResolution: 'Oorspronkelijke resolutie selecteren', selectResolution: 'Oorspronkelijke resolutie selecteren',
useSelectedArea: 'Resolutie van geselecteerd gebied gebruiken',
addResolution: 'Aangepaste resolutie toevoegen', addResolution: 'Aangepaste resolutie toevoegen',
add: 'Toevoegen', add: 'Toevoegen',
duplicateResolution: 'Deze resolutie bestaat al.', duplicateResolution: 'Deze resolutie bestaat al.',

View File

@@ -74,9 +74,10 @@ const pl = {
autoWarning: autoWarning:
'Kalibracja może się nie powieść, gdy aplikacja użytkownika ma całkowicie czarne tło.', 'Kalibracja może się nie powieść, gdy aplikacja użytkownika ma całkowicie czarne tło.',
manual: 'Ręczna', manual: 'Ręczna',
selectedResolution: 'Rozdzielczość zaznaczonego obszaru',
unused: 'Nieużywane',
originalResolution: 'Oryginalna rozdzielczość', originalResolution: 'Oryginalna rozdzielczość',
selectResolution: 'Wybierz oryginalną rozdzielczość', selectResolution: 'Wybierz oryginalną rozdzielczość',
useSelectedArea: 'Użyj rozdzielczości zaznaczonego obszaru',
addResolution: 'Dodaj niestandardową rozdzielczość', addResolution: 'Dodaj niestandardową rozdzielczość',
add: 'Dodaj', add: 'Dodaj',
duplicateResolution: 'Ta rozdzielczość już istnieje.', duplicateResolution: 'Ta rozdzielczość już istnieje.',

View File

@@ -73,9 +73,10 @@ const pt_br = {
autoWarning: autoWarning:
'A calibração pode falhar se o aplicativo do usuário tiver um fundo totalmente preto.', 'A calibração pode falhar se o aplicativo do usuário tiver um fundo totalmente preto.',
manual: 'Manual', manual: 'Manual',
selectedResolution: 'Resolução da área selecionada',
unused: 'Não utilizado',
originalResolution: 'Resolução original', originalResolution: 'Resolução original',
selectResolution: 'Selecionar resolução original', selectResolution: 'Selecionar resolução original',
useSelectedArea: 'Usar a resolução da área selecionada',
addResolution: 'Adicionar resolução personalizada', addResolution: 'Adicionar resolução personalizada',
add: 'Adicionar', add: 'Adicionar',
duplicateResolution: 'Esta resolução já existe.', duplicateResolution: 'Esta resolução já existe.',

View File

@@ -73,9 +73,10 @@ const ru = {
autoWarning: autoWarning:
'Калибровка может завершиться неудачей, если фон пользовательского приложения полностью чёрный.', 'Калибровка может завершиться неудачей, если фон пользовательского приложения полностью чёрный.',
manual: 'Вручную', manual: 'Вручную',
selectedResolution: 'Разрешение выбранной области',
unused: 'Не используется',
originalResolution: 'Исходное разрешение', originalResolution: 'Исходное разрешение',
selectResolution: 'Выберите исходное разрешение', selectResolution: 'Выберите исходное разрешение',
useSelectedArea: 'Использовать разрешение выбранной области',
addResolution: 'Добавить пользовательское разрешение', addResolution: 'Добавить пользовательское разрешение',
add: 'Добавить', add: 'Добавить',
duplicateResolution: 'Такое разрешение уже существует.', duplicateResolution: 'Такое разрешение уже существует.',

View File

@@ -70,9 +70,10 @@ const se = {
auto: 'Automatisk', auto: 'Automatisk',
autoWarning: 'Kalibreringen kan misslyckas om användarprogrammet har en helsvart bakgrund.', autoWarning: 'Kalibreringen kan misslyckas om användarprogrammet har en helsvart bakgrund.',
manual: 'Manuell', manual: 'Manuell',
selectedResolution: 'Valt områdesupplösning',
unused: 'Används inte',
originalResolution: 'Ursprunglig upplösning', originalResolution: 'Ursprunglig upplösning',
selectResolution: 'Välj ursprunglig upplösning', selectResolution: 'Välj ursprunglig upplösning',
useSelectedArea: 'Använd upplösningen för det valda området',
addResolution: 'Lägg till anpassad upplösning', addResolution: 'Lägg till anpassad upplösning',
add: 'Lägg till', add: 'Lägg till',
duplicateResolution: 'Den här upplösningen finns redan.', duplicateResolution: 'Den här upplösningen finns redan.',

View File

@@ -70,9 +70,10 @@ const th = {
auto: 'อัตโนมัติ', auto: 'อัตโนมัติ',
autoWarning: 'การปรับเทียบอาจล้มเหลวเมื่อแอปพลิเคชันของผู้ใช้มีพื้นหลังสีดำสนิท', autoWarning: 'การปรับเทียบอาจล้มเหลวเมื่อแอปพลิเคชันของผู้ใช้มีพื้นหลังสีดำสนิท',
manual: 'กำหนดเอง', manual: 'กำหนดเอง',
selectedResolution: 'ความละเอียดพื้นที่ที่เลือก',
unused: 'ไม่ได้ใช้',
originalResolution: 'ความละเอียดต้นฉบับ', originalResolution: 'ความละเอียดต้นฉบับ',
selectResolution: 'เลือกความละเอียดต้นฉบับ', selectResolution: 'เลือกความละเอียดต้นฉบับ',
useSelectedArea: 'ใช้ความละเอียดของพื้นที่ที่เลือก',
addResolution: 'เพิ่มความละเอียดแบบกำหนดเอง', addResolution: 'เพิ่มความละเอียดแบบกำหนดเอง',
add: 'เพิ่ม', add: 'เพิ่ม',
duplicateResolution: 'มีความละเอียดนี้อยู่แล้ว', duplicateResolution: 'มีความละเอียดนี้อยู่แล้ว',

View File

@@ -74,9 +74,10 @@ const tr = {
autoWarning: autoWarning:
'Kullanıcı uygulamasının arka planı tamamen siyah olduğunda kalibrasyon başarısız olabilir.', 'Kullanıcı uygulamasının arka planı tamamen siyah olduğunda kalibrasyon başarısız olabilir.',
manual: 'Manuel', manual: 'Manuel',
selectedResolution: 'Seçili Alan Çözünürlüğü',
unused: 'Kullanılmıyor',
originalResolution: 'Orijinal Çözünürlük', originalResolution: 'Orijinal Çözünürlük',
selectResolution: 'Orijinal çözünürlüğü seçin', selectResolution: 'Orijinal çözünürlüğü seçin',
useSelectedArea: 'Seçili alanın çözünürlüğünü kullan',
addResolution: 'Özel çözünürlük ekle', addResolution: 'Özel çözünürlük ekle',
add: 'Ekle', add: 'Ekle',
duplicateResolution: 'Bu çözünürlük zaten mevcut.', duplicateResolution: 'Bu çözünürlük zaten mevcut.',

View File

@@ -74,9 +74,10 @@ const uk = {
autoWarning: autoWarning:
'Калібрування може завершитися невдало, якщо користувацька програма має повністю чорне тло.', 'Калібрування може завершитися невдало, якщо користувацька програма має повністю чорне тло.',
manual: 'Вручну', manual: 'Вручну',
selectedResolution: 'Роздільна здатність вибраної області',
unused: 'Не використовується',
originalResolution: 'Початкова роздільна здатність', originalResolution: 'Початкова роздільна здатність',
selectResolution: 'Виберіть початкову роздільну здатність', selectResolution: 'Виберіть початкову роздільну здатність',
useSelectedArea: 'Використати роздільну здатність вибраної області',
addResolution: 'Додати власну роздільну здатність', addResolution: 'Додати власну роздільну здатність',
add: 'Додати', add: 'Додати',
duplicateResolution: 'Така роздільна здатність уже існує.', duplicateResolution: 'Така роздільна здатність уже існує.',

View File

@@ -73,9 +73,10 @@ const vi = {
autoWarning: autoWarning:
'Hiệu chỉnh có thể thất bại khi ứng dụng của người dùng có nền hoàn toàn màu đen.', 'Hiệu chỉnh có thể thất bại khi ứng dụng của người dùng có nền hoàn toàn màu đen.',
manual: 'Thủ công', manual: 'Thủ công',
selectedResolution: 'Độ phân giải vùng đã chọn',
unused: 'Không sử dụng',
originalResolution: 'Độ phân giải gốc', originalResolution: 'Độ phân giải gốc',
selectResolution: 'Chọn độ phân giải gốc', selectResolution: 'Chọn độ phân giải gốc',
useSelectedArea: 'Sử dụng độ phân giải của vùng đã chọn',
addResolution: 'Thêm độ phân giải tùy chỉnh', addResolution: 'Thêm độ phân giải tùy chỉnh',
add: 'Thêm', add: 'Thêm',
duplicateResolution: 'Độ phân giải này đã tồn tại.', duplicateResolution: 'Độ phân giải này đã tồn tại.',

View File

@@ -72,9 +72,10 @@ const zh = {
auto: '自动', auto: '自动',
autoWarning: '若用户程序为纯黑色背景,则可能校准出错。', autoWarning: '若用户程序为纯黑色背景,则可能校准出错。',
manual: '手动', manual: '手动',
selectedResolution: '框选分辨率',
unused: '未使用',
originalResolution: '原始分辨率', originalResolution: '原始分辨率',
selectResolution: '选择原始分辨率', selectResolution: '选择原始分辨率',
useSelectedArea: '使用框选分辨率',
addResolution: '添加自定义分辨率', addResolution: '添加自定义分辨率',
add: '添加', add: '添加',
duplicateResolution: '该分辨率已存在。', duplicateResolution: '该分辨率已存在。',

View File

@@ -69,9 +69,10 @@ const zh_tw = {
auto: '自動', auto: '自動',
autoWarning: '當使用者應用程式使用全黑背景時,校正可能會失敗。', autoWarning: '當使用者應用程式使用全黑背景時,校正可能會失敗。',
manual: '手動', manual: '手動',
selectedResolution: '框選解析度',
unused: '未使用',
originalResolution: '原始解析度', originalResolution: '原始解析度',
selectResolution: '選擇原始解析度', selectResolution: '選擇原始解析度',
useSelectedArea: '使用所選區域的解析度',
addResolution: '新增自訂解析度', addResolution: '新增自訂解析度',
add: '新增', add: '新增',
duplicateResolution: '此解析度已存在。', duplicateResolution: '此解析度已存在。',

View File

@@ -18,6 +18,8 @@ export const resolutionAtom = atom<Resolution | null>(null);
// currently effective absolute mouse input region // currently effective absolute mouse input region
export const inputRegionAtom = atom<InputRegion | null>(null); export const inputRegionAtom = atom<InputRegion | null>(null);
export const manualInputRegionAtom = atom<InputRegion | null>(null); export const manualInputRegionAtom = atom<InputRegion | null>(null);
export const manualRegionsAtom = atom<InputRegion[]>([]);
export const selectedManualRegionAtom = atom<string>('');
export const selectedOriginalResolutionAtom = atom<string>(''); export const selectedOriginalResolutionAtom = atom<string>('');
// device-level control region mode; disabled by default // device-level control region mode; disabled by default

View File

@@ -13,7 +13,9 @@ import {
controlRegionModeAtom, controlRegionModeAtom,
inputRegionAtom, inputRegionAtom,
manualInputRegionAtom, manualInputRegionAtom,
manualRegionsAtom,
resolutionAtom, resolutionAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom, selectedOriginalResolutionAtom,
videoModeAtom videoModeAtom
} from '@/jotai/screen.ts'; } from '@/jotai/screen.ts';
@@ -42,14 +44,11 @@ function getVideoMode() {
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg'; const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
const cookieVideoMode = storage.getVideoMode(); const cookieVideoMode = storage.getVideoMode();
if (cookieVideoMode) { if (!cookieVideoMode || (cookieVideoMode === 'direct' && !window.VideoDecoder)) {
if (cookieVideoMode === 'direct' && !window.VideoDecoder) { return defaultVideoMode;
return defaultVideoMode;
}
return cookieVideoMode;
} }
return defaultVideoMode; return ['direct', 'h264', 'mjpeg'].includes(cookieVideoMode) ? cookieVideoMode : defaultVideoMode;
} }
export const Desktop = () => { export const Desktop = () => {
@@ -64,6 +63,8 @@ export const Desktop = () => {
const [inputRegion, setInputRegion] = useAtom(inputRegionAtom); const [inputRegion, setInputRegion] = useAtom(inputRegionAtom);
const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom); const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom);
const setManualInputRegion = useSetAtom(manualInputRegionAtom); const setManualInputRegion = useSetAtom(manualInputRegionAtom);
const setManualRegions = useSetAtom(manualRegionsAtom);
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom); const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom); const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom);
@@ -76,6 +77,8 @@ export const Desktop = () => {
setResolution(res); setResolution(res);
setInputRegion(null); setInputRegion(null);
setManualInputRegion(null); setManualInputRegion(null);
setManualRegions([]);
setSelectedManualRegion('');
setSelectedOriginalResolution(''); setSelectedOriginalResolution('');
setControlRegionModeState('off'); setControlRegionModeState('off');
@@ -87,15 +90,24 @@ export const Desktop = () => {
? (config as InputRegion) ? (config as InputRegion)
: null; : null;
const selectedResolution = config?.selectedResolution || ''; const selectedResolution = config?.selectedResolution || '';
const regions = config?.regions || [];
const selectedRegion = config?.selectedRegion || '';
const selectedManualRegion = regions.find(
(region) => `${region.width}x${region.height}` === selectedRegion
);
setManualInputRegion(manualRegion); setManualInputRegion(manualRegion);
setManualRegions(regions);
setSelectedManualRegion(selectedRegion);
setSelectedOriginalResolution(selectedResolution); setSelectedOriginalResolution(selectedResolution);
setInputRegion(mode === 'manual' && !selectedResolution ? manualRegion : null); setInputRegion(mode === 'manual' && selectedRegion ? selectedManualRegion || null : null);
setControlRegionModeState(mode); setControlRegionModeState(mode);
}) })
.catch(() => { .catch(() => {
setControlRegionModeState('off'); setControlRegionModeState('off');
setInputRegion(null); setInputRegion(null);
setManualInputRegion(null); setManualInputRegion(null);
setManualRegions([]);
setSelectedManualRegion('');
setSelectedOriginalResolution(''); setSelectedOriginalResolution('');
}); });
@@ -107,7 +119,9 @@ export const Desktop = () => {
setControlRegionModeState, setControlRegionModeState,
setInputRegion, setInputRegion,
setManualInputRegion, setManualInputRegion,
setManualRegions,
setResolution, setResolution,
setSelectedManualRegion,
setSelectedOriginalResolution, setSelectedOriginalResolution,
setVideoMode setVideoMode
]); ]);
@@ -135,7 +149,7 @@ export const Desktop = () => {
clearTimeout(validationTimer); clearTimeout(validationTimer);
} }
validationTimer = setTimeout(() => { validationTimer = setTimeout(() => {
const mediaSize = getMediaSize(target); const mediaSize = getMediaSize(target, resolution);
if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) { if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) {
return; return;
} }

View File

@@ -11,3 +11,23 @@ export const BitRateMap = new Map([
[3, 2000], [3, 2000],
[4, 1000] [4, 1000]
]); ]);
export function getQualityMap(videoMode: string) {
if (videoMode === 'mjpeg') {
return QualityMap;
}
if (videoMode === 'direct' || videoMode === 'h264') {
return BitRateMap;
}
return null;
}
export function getScreenType(videoMode: string) {
if (videoMode === 'mjpeg') {
return 0;
}
if (videoMode === 'direct' || videoMode === 'h264') {
return 1;
}
return null;
}

View File

@@ -8,7 +8,7 @@ import * as ls from '@/lib/localstorage';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts'; import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { MenuItem } from '@/components/menu-item.tsx'; import { MenuItem } from '@/components/menu-item.tsx';
import { BitRateMap, QualityMap } from './constants.ts'; import { getQualityMap, getScreenType } from './constants.ts';
import { Fps } from './fps'; import { Fps } from './fps';
import { FrameDetect } from './frame-detect'; import { FrameDetect } from './frame-detect';
import { Gop } from './gop.tsx'; import { Gop } from './gop.tsx';
@@ -23,12 +23,16 @@ export const Screen = () => {
const videoMode = useAtomValue(videoModeAtom); const videoMode = useAtomValue(videoModeAtom);
const resolution = useAtomValue(resolutionAtom); const resolution = useAtomValue(resolutionAtom);
const isMjpeg = videoMode === 'mjpeg';
const [fps, setFps] = useState(30); const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(2); const [quality, setQuality] = useState(2);
const [gop, setGop] = useState(30); const [gop, setGop] = useState(30);
useEffect(() => { useEffect(() => {
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1); const screenType = getScreenType(videoMode);
if (screenType !== null) {
updateScreen('type', screenType);
}
updateScreen('resolution', resolution!.height); updateScreen('resolution', resolution!.height);
updateQuality(); updateQuality();
updateFps(); updateFps();
@@ -40,7 +44,9 @@ export const Screen = () => {
if (!cookieQuality) return; if (!cookieQuality) return;
const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2; const key = cookieQuality >= 1 && cookieQuality <= 4 ? cookieQuality : 2;
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!; const qualityMap = getQualityMap(videoMode);
const value = qualityMap?.get(key);
if (value === undefined) return;
const rsp = await updateScreen('quality', value); const rsp = await updateScreen('quality', value);
if (rsp.code === 0) { if (rsp.code === 0) {
@@ -75,8 +81,8 @@ export const Screen = () => {
<Quality quality={quality} setQuality={setQuality} /> <Quality quality={quality} setQuality={setQuality} />
<Fps fps={fps} setFps={setFps} /> <Fps fps={fps} setFps={setFps} />
<Scale /> <Scale />
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />} {!isMjpeg && <Gop gop={gop} setGop={setGop} />}
{videoMode === 'mjpeg' && <FrameDetect />} {isMjpeg && <FrameDetect />}
<Reset /> <Reset />
</div> </div>
); );

View File

@@ -12,12 +12,13 @@ import {
Tooltip, Tooltip,
Typography Typography
} from 'antd'; } from 'antd';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useSetAtom } from 'jotai';
import { ScanSearchIcon } from 'lucide-react'; import { ScanSearchIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
getInputRegion, getInputRegion,
setManualRegions as saveManualRegions,
setControlRegionMode, setControlRegionMode,
setOriginalResolutionConfig, setOriginalResolutionConfig,
setSelectedOriginalResolution setSelectedOriginalResolution
@@ -34,6 +35,8 @@ import {
inputRegionAtom, inputRegionAtom,
inputRegionSelectingAtom, inputRegionSelectingAtom,
manualInputRegionAtom, manualInputRegionAtom,
manualRegionsAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom selectedOriginalResolutionAtom
} from '@/jotai/screen.ts'; } from '@/jotai/screen.ts';
import { menuCloseSignalAtom } from '@/jotai/settings.ts'; import { menuCloseSignalAtom } from '@/jotai/settings.ts';
@@ -44,9 +47,12 @@ const resolutionKey = ({ width, height }: ResolutionPreset) => `${width}x${heigh
export const OriginalResolution = () => { export const OriginalResolution = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const setInputRegion = useSetAtom(inputRegionAtom); const setInputRegion = useSetAtom(inputRegionAtom);
const manualInputRegion = useAtomValue(manualInputRegionAtom);
const setManualInputRegion = useSetAtom(manualInputRegionAtom); const setManualInputRegion = useSetAtom(manualInputRegionAtom);
const [selectedResolution, setSelectedResolution] = useAtom(selectedOriginalResolutionAtom); const [manualRegions, setManualRegions] = useAtom(manualRegionsAtom);
const [selectedManualRegion, setSelectedManualRegion] = useAtom(selectedManualRegionAtom);
const [selectedOriginalResolution, setSelectedOriginalResolutionState] = useAtom(
selectedOriginalResolutionAtom
);
const [mode, setMode] = useAtom(controlRegionModeAtom); const [mode, setMode] = useAtom(controlRegionModeAtom);
const setSelecting = useSetAtom(inputRegionSelectingAtom); const setSelecting = useSetAtom(inputRegionSelectingAtom);
const setKeyboardLock = useSetAtom(keyboardLockAtom); const setKeyboardLock = useSetAtom(keyboardLockAtom);
@@ -74,7 +80,9 @@ export const OriginalResolution = () => {
if (rsp.code === 0) { if (rsp.code === 0) {
const config = rsp.data as ControlRegionConfig; const config = rsp.data as ControlRegionConfig;
setResolutions(config?.resolutions || []); setResolutions(config?.resolutions || []);
setSelectedResolution(config?.selectedResolution || ''); setManualRegions(config?.regions || []);
setSelectedManualRegion(config?.selectedRegion || '');
setSelectedOriginalResolutionState(config?.selectedResolution || '');
setManualInputRegion( setManualInputRegion(
isValidInputRegion(config as InputRegion) ? (config as InputRegion) : null isValidInputRegion(config as InputRegion) ? (config as InputRegion) : null
); );
@@ -82,18 +90,41 @@ export const OriginalResolution = () => {
return rsp; return rsp;
} }
async function applyResolution(key: string) { async function applyOriginalResolution(key: string) {
const rsp = await setSelectedOriginalResolution(key); const rsp = await setSelectedOriginalResolution(key);
if (rsp.code !== 0) { if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed')); messageApi.error(t('screen.controlRegion.saveFailed'));
return; return;
} }
setSelectedResolution(key); setSelectedOriginalResolutionState(key);
setSelectedManualRegion('');
if (!key) setInputRegion(null);
setIsPopoverOpen(true); setIsPopoverOpen(true);
} }
async function saveResolutions(next: ResolutionPreset[], nextSelected = selectedResolution) { async function applyManualRegion(key: string) {
const rsp = await setOriginalResolutionConfig(next, nextSelected); const rsp = await saveManualRegions(manualRegions, key);
if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed'));
return;
}
setSelectedManualRegion(key);
setSelectedOriginalResolutionState('');
setInputRegion(
manualRegions.find((region) => `${region.width}x${region.height}` === key) || null
);
setIsPopoverOpen(true);
}
async function saveResolutions(
next: ResolutionPreset[],
nextSelected = selectedOriginalResolution
) {
const rsp = await setOriginalResolutionConfig(
next,
nextSelected,
nextSelected ? '' : selectedManualRegion
);
if (rsp.code !== 0) { if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed')); messageApi.error(t('screen.controlRegion.saveFailed'));
return false; return false;
@@ -122,7 +153,7 @@ export const OriginalResolution = () => {
async function deleteResolution(index: number) { async function deleteResolution(index: number) {
if (index < 0 || index >= resolutions.length) return; if (index < 0 || index >= resolutions.length) return;
const deleted = resolutionKey(resolutions[index]); const deleted = resolutionKey(resolutions[index]);
const nextSelected = selectedResolution === deleted ? '' : selectedResolution; const nextSelected = selectedOriginalResolution === deleted ? '' : selectedOriginalResolution;
if ( if (
await saveResolutions( await saveResolutions(
resolutions.filter((_, itemIndex) => itemIndex !== index), resolutions.filter((_, itemIndex) => itemIndex !== index),
@@ -130,12 +161,29 @@ export const OriginalResolution = () => {
) )
) { ) {
if (!nextSelected) { if (!nextSelected) {
setSelectedResolution(''); setSelectedOriginalResolutionState('');
setInputRegion(manualInputRegion); setInputRegion(null);
} }
} }
} }
async function deleteManualRegion(index: number) {
if (index < 0 || index >= manualRegions.length) return;
const deleted = `${manualRegions[index].width}x${manualRegions[index].height}`;
const nextSelected = selectedManualRegion === deleted ? '' : selectedManualRegion;
const next = manualRegions.filter((_, itemIndex) => itemIndex !== index);
const rsp = await saveManualRegions(next, nextSelected, selectedOriginalResolution);
if (rsp.code !== 0) {
messageApi.error(t('screen.controlRegion.saveFailed'));
return;
}
setManualRegions(next);
if (!nextSelected) {
setSelectedManualRegion('');
setInputRegion(null);
}
}
async function updateMode(nextMode: ControlRegionMode) { async function updateMode(nextMode: ControlRegionMode) {
const rsp = await setControlRegionMode(nextMode); const rsp = await setControlRegionMode(nextMode);
if (rsp.code !== 0) { if (rsp.code !== 0) {
@@ -148,9 +196,14 @@ export const OriginalResolution = () => {
const manualRegion = isValidInputRegion(config.data as InputRegion) const manualRegion = isValidInputRegion(config.data as InputRegion)
? (config.data as InputRegion) ? (config.data as InputRegion)
: null; : null;
const selectedRegion = (config.data as ControlRegionConfig)?.selectedRegion || '';
const selectedResolution = (config.data as ControlRegionConfig)?.selectedResolution || '';
const regions = (config.data as ControlRegionConfig)?.regions || [];
setManualInputRegion(manualRegion); setManualInputRegion(manualRegion);
setInputRegion( setInputRegion(
(config.data as ControlRegionConfig)?.selectedResolution ? null : manualRegion selectedResolution
? null
: regions.find((region) => `${region.width}x${region.height}` === selectedRegion) || null
); );
} else { } else {
setInputRegion(null); setInputRegion(null);
@@ -195,19 +248,58 @@ export const OriginalResolution = () => {
<Button block type="primary" icon={<ScanSearchIcon size={14} />} onClick={selectArea}> <Button block type="primary" icon={<ScanSearchIcon size={14} />} onClick={selectArea}>
{t('screen.controlRegion.select')} {t('screen.controlRegion.select')}
</Button> </Button>
<Typography.Text>{t('screen.controlRegion.selectedResolution')}</Typography.Text>
<Select
className="w-full"
value={selectedManualRegion}
options={[
{ label: t('screen.controlRegion.unused'), value: '' },
...manualRegions.map((region) => ({
label: `${region.width}x${region.height}`,
value: `${region.width}x${region.height}`
}))
]}
optionRender={(option) => (
<div className="flex items-center justify-between">
<span>{option.label}</span>
{option.value !== '' && (
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
deleteManualRegion(
manualRegions.findIndex(
(region) => `${region.width}x${region.height}` === option.value
)
);
}}
/>
)}
</div>
)}
onChange={applyManualRegion}
/>
<Typography.Text>{t('screen.controlRegion.originalResolution')}</Typography.Text> <Typography.Text>{t('screen.controlRegion.originalResolution')}</Typography.Text>
<Space.Compact block> <Space.Compact block>
<Select <Select
className="w-full" className="w-full"
value={selectedResolution} value={selectedOriginalResolution}
placeholder={t('screen.controlRegion.selectResolution')} placeholder={t('screen.controlRegion.selectResolution')}
getPopupContainer={(trigger) => trigger.parentElement || document.body} getPopupContainer={(trigger) => trigger.parentElement || document.body}
options={resolutions options={[
.map((resolution) => ({ { label: t('screen.controlRegion.unused'), value: '' },
...resolutions.map((resolution) => ({
label: resolutionKey(resolution), label: resolutionKey(resolution),
value: resolutionKey(resolution) value: resolutionKey(resolution)
})) }))
.concat([{ label: t('screen.controlRegion.useSelectedArea'), value: '' }])} ]}
optionRender={(option) => ( optionRender={(option) => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{option.label}</span> <span>{option.label}</span>
@@ -233,7 +325,7 @@ export const OriginalResolution = () => {
)} )}
</div> </div>
)} )}
onChange={applyResolution} onChange={applyOriginalResolution}
/> />
<Tooltip title={t('screen.controlRegion.addResolution')}> <Tooltip title={t('screen.controlRegion.addResolution')}>
<Button icon={<PlusOutlined />} onClick={() => setIsAddOpen(true)} /> <Button icon={<PlusOutlined />} onClick={() => setIsAddOpen(true)} />

View File

@@ -7,7 +7,7 @@ import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts'; import { setQuality as setCookie } from '@/lib/localstorage.ts';
import { videoModeAtom } from '@/jotai/screen.ts'; import { videoModeAtom } from '@/jotai/screen.ts';
import { BitRateMap, QualityMap } from './constants.ts'; import { getQualityMap } from './constants.ts';
type QualityProps = { type QualityProps = {
quality: number; quality: number;
@@ -26,7 +26,11 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
]; ];
async function update(key: number) { async function update(key: number) {
const value = videoMode === 'mjpeg' ? QualityMap.get(key)! : BitRateMap.get(key)!; const qualityMap = getQualityMap(videoMode);
const value = qualityMap?.get(key);
if (value === undefined) {
return;
}
const rsp = await updateScreen('quality', value); const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) { if (rsp.code !== 0) {

View File

@@ -4,14 +4,16 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { import {
controlRegionModeAtom, controlRegionModeAtom,
inputRegionAtom, inputRegionAtom,
inputRegionSelectingAtom inputRegionSelectingAtom,
resolutionAtom
} from '@/jotai/screen.ts'; } from '@/jotai/screen.ts';
import { detectFrameContent, getMediaSize, isMediaReady } from './geometry.ts'; import { detectFrameContent, getMediaSize } from './geometry.ts';
export const AutoRegion = () => { export const AutoRegion = () => {
const mode = useAtomValue(controlRegionModeAtom); const mode = useAtomValue(controlRegionModeAtom);
const selecting = useAtomValue(inputRegionSelectingAtom); const selecting = useAtomValue(inputRegionSelectingAtom);
const resolution = useAtomValue(resolutionAtom);
const setInputRegion = useSetAtom(inputRegionAtom); const setInputRegion = useSetAtom(inputRegionAtom);
useEffect(() => { useEffect(() => {
@@ -19,18 +21,25 @@ export const AutoRegion = () => {
return; return;
} }
const screen = document.getElementById('screen');
if (!screen) return;
const target = screen;
let stopped = false; let stopped = false;
let target: Element | null = null;
let candidate = ''; let candidate = '';
let confirmations = 0; let confirmations = 0;
const detect = () => { const detect = () => {
if (stopped || !isMediaReady(target)) return; if (stopped) return;
const mediaSize = getMediaSize(target);
const screen = document.getElementById('screen');
if (!screen) return;
if (screen !== target) {
target = screen;
candidate = '';
confirmations = 0;
}
const mediaSize = getMediaSize(screen, resolution);
if (!mediaSize) return; if (!mediaSize) return;
const content = detectFrameContent(target, mediaSize); const content = detectFrameContent(screen, mediaSize);
const key = [ const key = [
mediaSize.width, mediaSize.width,
mediaSize.height, mediaSize.height,
@@ -65,7 +74,7 @@ export const AutoRegion = () => {
stopped = true; stopped = true;
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [mode, selecting, setInputRegion]); }, [mode, resolution, selecting, setInputRegion]);
useEffect(() => { useEffect(() => {
if (mode !== 'auto') setInputRegion(null); if (mode !== 'auto') setInputRegion(null);

View File

@@ -15,6 +15,10 @@ export type FrameContent = {
export type RenderedMediaRect = FrameContent; export type RenderedMediaRect = FrameContent;
const aspectRatioTolerance = 0.01; const aspectRatioTolerance = 0.01;
const mediaWidthAttribute = 'data-media-width';
const mediaHeightAttribute = 'data-media-height';
const blackPixelThreshold = 32;
const darkSampleRatio = 0.75;
export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({ export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
left: 0, left: 0,
@@ -23,7 +27,13 @@ export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
height: mediaSize.height height: mediaSize.height
}); });
export function getMediaSize(screen: Element): MediaSize | null { export function getMediaSize(screen: Element, fallback?: MediaSize | null): MediaSize | null {
const dataWidth = Number(screen.getAttribute(mediaWidthAttribute));
const dataHeight = Number(screen.getAttribute(mediaHeightAttribute));
if (dataWidth > 0 && dataHeight > 0) {
return { width: dataWidth, height: dataHeight };
}
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) { if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
return { width: screen.videoWidth, height: screen.videoHeight }; return { width: screen.videoWidth, height: screen.videoHeight };
} }
@@ -32,28 +42,20 @@ export function getMediaSize(screen: Element): MediaSize | null {
return { width: screen.naturalWidth, height: screen.naturalHeight }; return { width: screen.naturalWidth, height: screen.naturalHeight };
} }
if (screen instanceof HTMLCanvasElement) { return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null;
const width = Number(screen.dataset.mediaWidth);
const height = Number(screen.dataset.mediaHeight);
if (width > 0 && height > 0) {
return { width, height };
}
}
return null;
} }
export function isMediaReady(screen: Element) { export function isMediaReady(screen: Element) {
if (screen instanceof HTMLVideoElement) { if (screen instanceof HTMLVideoElement) {
return screen.readyState >= 2 && screen.videoWidth > 0 && screen.videoHeight > 0; return screen.readyState >= 2 && !!getMediaSize(screen);
} }
if (screen instanceof HTMLImageElement) { if (screen instanceof HTMLImageElement) {
return screen.complete && screen.naturalWidth > 0 && screen.naturalHeight > 0; return !!getMediaSize(screen);
} }
if (screen instanceof HTMLCanvasElement) { if (screen instanceof HTMLCanvasElement) {
return Number(screen.dataset.mediaWidth) > 0 && Number(screen.dataset.mediaHeight) > 0; return !!getMediaSize(screen);
} }
return false; return false;
@@ -73,20 +75,33 @@ export function detectFrameContent(screen: Element, mediaSize: MediaSize): Frame
try { try {
const pixels = context.getImageData(0, 0, width, height).data; const pixels = context.getImageData(0, 0, width, height).data;
const left = findBorderInset(pixels, width, height, true, 'horizontal'); let top = findBorderInset(pixels, width, height, true, 'vertical');
const right = findBorderInset(pixels, width, height, false, 'horizontal'); let bottom = findBorderInset(pixels, width, height, false, 'vertical');
const top = findBorderInset(pixels, width, height, true, 'vertical'); let verticalBorder = isSymmetricBorder(top, bottom, height);
const bottom = findBorderInset(pixels, width, height, false, 'vertical'); const left = findBorderInset(
const horizontalBorder = pixels,
left >= Math.max(4, Math.round(width * 0.02)) && width,
right >= Math.max(4, Math.round(width * 0.02)) && height,
left + right < width * 0.4 && true,
Math.abs(left - right) <= Math.max(4, Math.round(width * 0.05)); 'horizontal',
const verticalBorder = verticalBorder ? top : 0,
top >= Math.max(4, Math.round(height * 0.02)) && verticalBorder ? height - bottom : height
bottom >= Math.max(4, Math.round(height * 0.02)) && );
top + bottom < height * 0.4 && const right = findBorderInset(
Math.abs(top - bottom) <= Math.max(4, Math.round(height * 0.05)); pixels,
width,
height,
false,
'horizontal',
verticalBorder ? top : 0,
verticalBorder ? height - bottom : height
);
const horizontalBorder = isSymmetricBorder(left, right, width);
if (horizontalBorder) {
top = findBorderInset(pixels, width, height, true, 'vertical', left, width - right);
bottom = findBorderInset(pixels, width, height, false, 'vertical', left, width - right);
verticalBorder = isSymmetricBorder(top, bottom, height);
}
const frameLeft = horizontalBorder ? (left / width) * mediaSize.width : 0; const frameLeft = horizontalBorder ? (left / width) * mediaSize.width : 0;
const frameRight = horizontalBorder ? (right / width) * mediaSize.width : 0; const frameRight = horizontalBorder ? (right / width) * mediaSize.width : 0;
const frameTop = verticalBorder ? (top / height) * mediaSize.height : 0; const frameTop = verticalBorder ? (top / height) * mediaSize.height : 0;
@@ -128,28 +143,43 @@ function findBorderInset(
width: number, width: number,
height: number, height: number,
fromStart: boolean, fromStart: boolean,
axis: 'horizontal' | 'vertical' axis: 'horizontal' | 'vertical',
sampleStart = 0,
sampleEnd = axis === 'horizontal' ? height : width
) { ) {
const scanSize = axis === 'horizontal' ? width : height; const scanSize = axis === 'horizontal' ? width : height;
const sampleSize = axis === 'horizontal' ? height : width; const sampleSize = Math.max(1, sampleEnd - sampleStart);
const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map((ratio) => const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map(
Math.round((sampleSize - 1) * ratio) (ratio) => sampleStart + Math.round((sampleSize - 1) * ratio)
); );
const limit = Math.floor(scanSize * 0.45); const limit = Math.floor(scanSize * 0.45);
let inset = 0; let inset = 0;
for (let offset = 0; offset < limit; offset++) { for (let offset = 0; offset < limit; offset++) {
const black = lines.every((line) => { const darkSamples = lines.filter((line) => {
const x = axis === 'horizontal' ? (fromStart ? offset : width - offset - 1) : line; const x = axis === 'horizontal' ? (fromStart ? offset : width - offset - 1) : line;
const y = axis === 'horizontal' ? line : fromStart ? offset : height - offset - 1; const y = axis === 'horizontal' ? line : fromStart ? offset : height - offset - 1;
const index = (y * width + x) * 4; const index = (y * width + x) * 4;
return pixels[index] === 0 && pixels[index + 1] === 0 && pixels[index + 2] === 0; return (
}); pixels[index] <= blackPixelThreshold &&
pixels[index + 1] <= blackPixelThreshold &&
pixels[index + 2] <= blackPixelThreshold
);
}).length;
const black = darkSamples / lines.length >= darkSampleRatio;
if (!black) break; if (!black) break;
inset = offset + 1; inset = offset + 1;
} }
return inset; return inset;
} }
function isSymmetricBorder(start: number, end: number, size: number) {
return (
start >= Math.max(4, Math.round(size * 0.02)) &&
end >= Math.max(4, Math.round(size * 0.02)) &&
Math.abs(start - end) <= Math.max(4, Math.round(size * 0.05))
);
}
export function getRenderedMediaRect( export function getRenderedMediaRect(
elementRect: DOMRect, elementRect: DOMRect,
mediaSize: MediaSize mediaSize: MediaSize

View File

@@ -17,5 +17,9 @@ export const Screen = () => {
return <H264Direct />; return <H264Direct />;
} }
return <H264Webrtc />; if (videoMode === 'h264') {
return <H264Webrtc />;
}
return null;
}; };

View File

@@ -12,6 +12,9 @@ import {
inputRegionAtom, inputRegionAtom,
inputRegionSelectingAtom, inputRegionSelectingAtom,
manualInputRegionAtom, manualInputRegionAtom,
manualRegionsAtom,
resolutionAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom, selectedOriginalResolutionAtom,
videoScaleAtom videoScaleAtom
} from '@/jotai/screen.ts'; } from '@/jotai/screen.ts';
@@ -75,6 +78,7 @@ const resizeHandles: Array<{
]; ];
const minimumSelectionSize = 4; const minimumSelectionSize = 4;
const edgeSnapDistance = 8;
export const InputRegionOverlay = () => { export const InputRegionOverlay = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -82,6 +86,9 @@ export const InputRegionOverlay = () => {
const [selecting, setSelecting] = useAtom(inputRegionSelectingAtom); const [selecting, setSelecting] = useAtom(inputRegionSelectingAtom);
const [, setInputRegionState] = useAtom(inputRegionAtom); const [, setInputRegionState] = useAtom(inputRegionAtom);
const setManualInputRegion = useSetAtom(manualInputRegionAtom); const setManualInputRegion = useSetAtom(manualInputRegionAtom);
const [manualRegions, setManualRegions] = useAtom(manualRegionsAtom);
const resolution = useAtomValue(resolutionAtom);
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom); const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
const videoScale = useAtomValue(videoScaleAtom); const videoScale = useAtomValue(videoScaleAtom);
const setVideoScale = useSetAtom(videoScaleAtom); const setVideoScale = useSetAtom(videoScaleAtom);
@@ -151,17 +158,15 @@ export const InputRegionOverlay = () => {
return; return;
} }
const screen = document.getElementById('screen');
if (!screen) {
setFrameRect(null);
setMediaSize(null);
frameRectRef.current = null;
return;
}
const target = screen;
function updateFrame() { function updateFrame() {
const nextMediaSize = getMediaSize(target); const target = document.getElementById('screen');
if (!target) {
setFrameRect(null);
setMediaSize(null);
frameRectRef.current = null;
return;
}
const nextMediaSize = getMediaSize(target, resolution);
const bounds = target.getBoundingClientRect(); const bounds = target.getBoundingClientRect();
if (!nextMediaSize || bounds.width <= 0 || bounds.height <= 0) { if (!nextMediaSize || bounds.width <= 0 || bounds.height <= 0) {
setFrameRect(null); setFrameRect(null);
@@ -192,31 +197,16 @@ export const InputRegionOverlay = () => {
} }
updateFrame(); updateFrame();
const resizeObserver = new ResizeObserver(updateFrame);
resizeObserver.observe(target);
const mutationObserver = new MutationObserver(updateFrame);
mutationObserver.observe(target, {
attributes: true,
attributeFilter: ['data-media-width', 'data-media-height']
});
window.addEventListener('resize', updateFrame); window.addEventListener('resize', updateFrame);
window.addEventListener('scroll', updateFrame, true); window.addEventListener('scroll', updateFrame, true);
target.addEventListener('load', updateFrame); const timer = window.setInterval(updateFrame, 250);
target.addEventListener('loadedmetadata', updateFrame);
target.addEventListener('canplay', updateFrame);
target.addEventListener('resize', updateFrame);
return () => { return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener('resize', updateFrame); window.removeEventListener('resize', updateFrame);
window.removeEventListener('scroll', updateFrame, true); window.removeEventListener('scroll', updateFrame, true);
target.removeEventListener('load', updateFrame); window.clearInterval(timer);
target.removeEventListener('loadedmetadata', updateFrame);
target.removeEventListener('canplay', updateFrame);
target.removeEventListener('resize', updateFrame);
}; };
}, [selecting, videoScale]); }, [resolution, selecting, videoScale]);
useEffect(() => { useEffect(() => {
if (!selecting) { if (!selecting) {
@@ -345,12 +335,20 @@ export const InputRegionOverlay = () => {
return null; return null;
} }
const right = frameRect.left + frameRect.width;
const bottom = frameRect.top + frameRect.height;
return { return {
x: Math.max(frameRect.left, Math.min(frameRect.left + frameRect.width, x)), x: snapToEdge(Math.max(frameRect.left, Math.min(right, x)), frameRect.left, right),
y: Math.max(frameRect.top, Math.min(frameRect.top + frameRect.height, y)) y: snapToEdge(Math.max(frameRect.top, Math.min(bottom, y)), frameRect.top, bottom)
}; };
} }
function snapToEdge(value: number, start: number, end: number) {
if (Math.abs(value - start) <= edgeSnapDistance) return start;
if (Math.abs(value - end) <= edgeSnapDistance) return end;
return value;
}
function updateSelection(x: number, y: number) { function updateSelection(x: number, y: number) {
const start = dragStartRef.current; const start = dragStartRef.current;
const point = clampPoint(x, y); const point = clampPoint(x, y);
@@ -448,17 +446,19 @@ export const InputRegionOverlay = () => {
setCursorPoint(point); setCursorPoint(point);
} }
function handlePointerDown(event: PointerEvent<HTMLDivElement>) { function updateHoverCursorPoint(x: number, y: number) {
if ( if (!frameRect || selection) {
!frameRect || updateCursorPoint(x, y);
event.clientX < frameRect.left ||
event.clientX > frameRect.left + frameRect.width ||
event.clientY < frameRect.top ||
event.clientY > frameRect.top + frameRect.height
) {
return; return;
} }
const point = clampPoint(x, y);
if (point) {
updateCursorPoint(point.x, point.y, true);
}
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
const point = clampPoint(event.clientX, event.clientY); const point = clampPoint(event.clientX, event.clientY);
if (!point) { if (!point) {
return; return;
@@ -480,11 +480,14 @@ export const InputRegionOverlay = () => {
setCursorPoint(null); setCursorPoint(null);
} else if (resizeRef.current) { } else if (resizeRef.current) {
updateResizedSelection(event.clientX, event.clientY); updateResizedSelection(event.clientX, event.clientY);
} else { } else if (dragging) {
updateCursorPoint(event.clientX, event.clientY, dragging); const point = clampPoint(event.clientX, event.clientY);
if (dragging) { if (point) {
updateSelection(event.clientX, event.clientY); updateCursorPoint(point.x, point.y, true);
updateSelection(point.x, point.y);
} }
} else {
updateHoverCursorPoint(event.clientX, event.clientY);
} }
if (moveRef.current) { if (moveRef.current) {
updateMovedSelection(event.clientX, event.clientY); updateMovedSelection(event.clientX, event.clientY);
@@ -575,14 +578,20 @@ export const InputRegionOverlay = () => {
width: Math.max(1, right - left), width: Math.max(1, right - left),
height: Math.max(1, bottom - top) height: Math.max(1, bottom - top)
}; };
const key = `${region.width}x${region.height}`;
const regions = manualRegions.some((item) => `${item.width}x${item.height}` === key)
? manualRegions.map((item) => (`${item.width}x${item.height}` === key ? region : item))
: [...manualRegions, region];
const rsp = await setInputRegionConfig(region, ''); const rsp = await setInputRegionConfig(region, regions, key);
if (rsp.code !== 0) { if (rsp.code !== 0) {
setError(t('screen.controlRegion.saveFailed')); setError(t('screen.controlRegion.saveFailed'));
return; return;
} }
setManualInputRegion(region); setManualInputRegion(region);
setManualRegions(regions);
setSelectedManualRegion(key);
setSelectedOriginalResolution(''); setSelectedOriginalResolution('');
setInputRegionState(region); setInputRegionState(region);
cursorPointRef.current = null; cursorPointRef.current = null;

View File

@@ -4,7 +4,9 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { import {
controlRegionModeAtom, controlRegionModeAtom,
inputRegionAtom, inputRegionAtom,
manualInputRegionAtom, manualRegionsAtom,
resolutionAtom,
selectedManualRegionAtom,
selectedOriginalResolutionAtom selectedOriginalResolutionAtom
} from '@/jotai/screen.ts'; } from '@/jotai/screen.ts';
@@ -12,52 +14,47 @@ import { getCenteredInputRegionByAspectRatio, getMediaSize } from './geometry.ts
export const ManualRegion = () => { export const ManualRegion = () => {
const mode = useAtomValue(controlRegionModeAtom); const mode = useAtomValue(controlRegionModeAtom);
const selected = useAtomValue(selectedOriginalResolutionAtom); const selectedOriginal = useAtomValue(selectedOriginalResolutionAtom);
const manualRegion = useAtomValue(manualInputRegionAtom); const selectedManual = useAtomValue(selectedManualRegionAtom);
const manualRegions = useAtomValue(manualRegionsAtom);
const resolution = useAtomValue(resolutionAtom);
const setInputRegion = useSetAtom(inputRegionAtom); const setInputRegion = useSetAtom(inputRegionAtom);
useEffect(() => { useEffect(() => {
if (mode !== 'manual') return; if (mode !== 'manual') return;
if (!selected) { if (selectedManual) {
setInputRegion(manualRegion); setInputRegion(
manualRegions.find((region) => `${region.width}x${region.height}` === selectedManual) ||
null
);
return; return;
} }
const [width, height] = selected.split('x').map(Number); if (!selectedOriginal) {
if (manualRegion?.width === width && manualRegion.height === height) {
setInputRegion(manualRegion);
return;
}
const screen = document.getElementById('screen');
if (!screen || !width || !height) {
setInputRegion(null); setInputRegion(null);
return; return;
} }
const target = screen;
const [width, height] = selectedOriginal.split('x').map(Number);
if (!width || !height) {
setInputRegion(null);
return;
}
const update = () => { const update = () => {
const mediaSize = getMediaSize(target); const target = document.getElementById('screen');
if (!target) return;
const mediaSize = getMediaSize(target, resolution);
setInputRegion( setInputRegion(
mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null
); );
}; };
update(); update();
const observer = new MutationObserver(update); const timer = window.setInterval(update, 250);
observer.observe(target, {
attributes: true,
attributeFilter: ['data-media-width', 'data-media-height']
});
target.addEventListener('load', update);
target.addEventListener('loadedmetadata', update);
target.addEventListener('canplay', update);
target.addEventListener('resize', update);
return () => { return () => {
observer.disconnect(); window.clearInterval(timer);
target.removeEventListener('load', update);
target.removeEventListener('loadedmetadata', update);
target.removeEventListener('canplay', update);
target.removeEventListener('resize', update);
}; };
}, [manualRegion, mode, selected, setInputRegion]); }, [manualRegions, mode, resolution, selectedManual, selectedOriginal, setInputRegion]);
return null; return null;
}; };

View File

@@ -20,4 +20,6 @@ export type ControlRegionConfig = Partial<InputRegion> & {
mode: ControlRegionMode; mode: ControlRegionMode;
resolutions?: OriginalResolution[]; resolutions?: OriginalResolution[];
selectedResolution?: string; selectedResolution?: string;
regions?: InputRegion[];
selectedRegion?: string;
}; };