mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
@@ -163,6 +163,17 @@ type InputRegion struct {
|
||||
Height int `json:"height"`
|
||||
Resolutions []OriginalResolution `json:"resolutions,omitempty"`
|
||||
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 {
|
||||
@@ -180,6 +191,8 @@ type SetInputRegionReq struct {
|
||||
Height *int `json:"height,omitempty"`
|
||||
Resolutions *[]OriginalResolution `json:"resolutions,omitempty"`
|
||||
SelectedResolution *string `json:"selectedResolution,omitempty"`
|
||||
Regions *[]ManualRegion `json:"regions,omitempty"`
|
||||
SelectedRegion *string `json:"selectedRegion,omitempty"`
|
||||
}
|
||||
|
||||
type GetInputRegionRsp struct {
|
||||
|
||||
@@ -99,7 +99,20 @@ func (s *Service) SetInputRegion(c *gin.Context) {
|
||||
if req.SelectedResolution != nil {
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -127,6 +140,8 @@ func (s *Service) SetInputRegion(c *gin.Context) {
|
||||
if previous != nil {
|
||||
region.Resolutions = previous.Resolutions
|
||||
region.SelectedResolution = previous.SelectedResolution
|
||||
region.Regions = previous.Regions
|
||||
region.SelectedRegion = previous.SelectedRegion
|
||||
}
|
||||
if req.Resolutions != nil {
|
||||
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
||||
@@ -138,7 +153,20 @@ func (s *Service) SetInputRegion(c *gin.Context) {
|
||||
if req.SelectedResolution != nil {
|
||||
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(®ion)
|
||||
}
|
||||
}
|
||||
if req.SelectedRegion != nil {
|
||||
region.SelectedRegion = *req.SelectedRegion
|
||||
}
|
||||
if err := validateSelections(region); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
||||
return
|
||||
}
|
||||
@@ -176,10 +204,68 @@ func validateSelectedResolution(selected string, resolutions []proto.OriginalRes
|
||||
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 {
|
||||
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) {
|
||||
if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil ||
|
||||
req.Top == nil || req.Width == nil || req.Height == nil {
|
||||
@@ -236,9 +322,29 @@ func readInputRegion(path string) (*proto.InputRegion, error) {
|
||||
if region.Mode == "" {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,17 @@ export function setInputRegion(region: InputRegion) {
|
||||
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', {
|
||||
mode: 'manual',
|
||||
...region,
|
||||
selectedResolution
|
||||
regions,
|
||||
selectedRegion,
|
||||
selectedResolution: ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,17 +72,36 @@ export function setOriginalResolutions(mode: ControlRegionMode, resolutions: Ori
|
||||
|
||||
export function setOriginalResolutionConfig(
|
||||
resolutions: OriginalResolution[],
|
||||
selectedResolution: string
|
||||
selectedResolution: string,
|
||||
selectedRegion: string
|
||||
) {
|
||||
return http.post('/api/vm/input-region', {
|
||||
mode: 'manual',
|
||||
resolutions,
|
||||
selectedResolution
|
||||
selectedResolution,
|
||||
selectedRegion
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -73,9 +73,10 @@ const ca = {
|
||||
autoWarning:
|
||||
"El calibratge pot fallar quan l'aplicació de l'usuari tingui un fons completament negre.",
|
||||
manual: 'Manual',
|
||||
selectedResolution: "Resolució de l'àrea seleccionada",
|
||||
unused: 'No utilitzada',
|
||||
originalResolution: 'Resolució original',
|
||||
selectResolution: 'Selecciona la resolució original',
|
||||
useSelectedArea: "Utilitza la resolució de l'àrea seleccionada",
|
||||
addResolution: 'Afegeix una resolució personalitzada',
|
||||
add: 'Afegeix',
|
||||
duplicateResolution: 'Aquesta resolució ja existeix.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const cz = {
|
||||
auto: 'Automaticky',
|
||||
autoWarning: 'Kalibrace může selhat, pokud má uživatelská aplikace zcela černé pozadí.',
|
||||
manual: 'Ručně',
|
||||
selectedResolution: 'Rozlišení vybrané oblasti',
|
||||
unused: 'Nepoužito',
|
||||
originalResolution: 'Původní rozlišení',
|
||||
selectResolution: 'Vyberte původní rozlišení',
|
||||
useSelectedArea: 'Použít rozlišení vybrané oblasti',
|
||||
addResolution: 'Přidat vlastní rozlišení',
|
||||
add: 'Přidat',
|
||||
duplicateResolution: 'Toto rozlišení již existuje.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const da = {
|
||||
autoWarning:
|
||||
'Kalibreringen kan mislykkes, hvis brugerprogrammet har en helt sort baggrund.',
|
||||
manual: 'Manuel',
|
||||
selectedResolution: 'Valgt områdeopløsning',
|
||||
unused: 'Ikke i brug',
|
||||
originalResolution: 'Oprindelig opløsning',
|
||||
selectResolution: 'Vælg oprindelig opløsning',
|
||||
useSelectedArea: 'Brug det valgte områdes opløsning',
|
||||
addResolution: 'Tilføj brugerdefineret opløsning',
|
||||
add: 'Tilføj',
|
||||
duplicateResolution: 'Denne opløsning findes allerede.',
|
||||
|
||||
@@ -76,9 +76,10 @@ const de = {
|
||||
autoWarning:
|
||||
'Die Kalibrierung kann fehlschlagen, wenn die Benutzeranwendung einen vollständig schwarzen Hintergrund hat.',
|
||||
manual: 'Manuell',
|
||||
selectedResolution: 'Auflösung des ausgewählten Bereichs',
|
||||
unused: 'Nicht verwendet',
|
||||
originalResolution: 'Originalauflösung',
|
||||
selectResolution: 'Originalauflösung auswählen',
|
||||
useSelectedArea: 'Auflösung des ausgewählten Bereichs verwenden',
|
||||
addResolution: 'Benutzerdefinierte Auflösung hinzufügen',
|
||||
add: 'Hinzufügen',
|
||||
duplicateResolution: 'Diese Auflösung ist bereits vorhanden.',
|
||||
|
||||
@@ -74,9 +74,10 @@ const en = {
|
||||
auto: 'Auto',
|
||||
autoWarning: 'Calibration may fail when the user application has a pure black background.',
|
||||
manual: 'Manual',
|
||||
selectedResolution: 'Selected Area Resolution',
|
||||
unused: 'Not used',
|
||||
originalResolution: 'Original Resolution',
|
||||
selectResolution: 'Select original resolution',
|
||||
useSelectedArea: 'Use selected area resolution',
|
||||
addResolution: 'Add custom resolution',
|
||||
add: 'Add',
|
||||
duplicateResolution: 'This resolution already exists.',
|
||||
|
||||
@@ -74,9 +74,10 @@ const es = {
|
||||
autoWarning:
|
||||
'La calibración puede fallar si la aplicación del usuario tiene un fondo completamente negro.',
|
||||
manual: 'Manual',
|
||||
selectedResolution: 'Resolución del área seleccionada',
|
||||
unused: 'Sin usar',
|
||||
originalResolution: 'Resolución original',
|
||||
selectResolution: 'Seleccionar resolución original',
|
||||
useSelectedArea: 'Usar la resolución del área seleccionada',
|
||||
addResolution: 'Añadir resolución personalizada',
|
||||
add: 'Añadir',
|
||||
duplicateResolution: 'Esta resolución ya existe.',
|
||||
|
||||
@@ -76,9 +76,10 @@ const fr = {
|
||||
autoWarning:
|
||||
"L'étalonnage peut échouer si l'application utilisateur présente un arrière-plan entièrement noir.",
|
||||
manual: 'Manuel',
|
||||
selectedResolution: 'Résolution de la zone sélectionnée',
|
||||
unused: 'Non utilisée',
|
||||
originalResolution: "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',
|
||||
add: 'Ajouter',
|
||||
duplicateResolution: 'Cette résolution existe déjà.',
|
||||
|
||||
@@ -75,9 +75,10 @@ const hu = {
|
||||
autoWarning:
|
||||
'A kalibrálás sikertelen lehet, ha a felhasználói alkalmazás háttere teljesen fekete.',
|
||||
manual: 'Kézi',
|
||||
selectedResolution: 'Kijelölt terület felbontása',
|
||||
unused: 'Nincs használatban',
|
||||
originalResolution: 'Eredeti felbontás',
|
||||
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',
|
||||
add: 'Hozzáadás',
|
||||
duplicateResolution: 'Ez a felbontás már létezik.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const id = {
|
||||
autoWarning:
|
||||
'Kalibrasi mungkin gagal jika aplikasi pengguna menggunakan latar belakang hitam pekat.',
|
||||
manual: 'Manual',
|
||||
selectedResolution: 'Resolusi Area Terpilih',
|
||||
unused: 'Tidak digunakan',
|
||||
originalResolution: 'Resolusi Asli',
|
||||
selectResolution: 'Pilih resolusi asli',
|
||||
useSelectedArea: 'Gunakan resolusi area yang dipilih',
|
||||
addResolution: 'Tambahkan resolusi khusus',
|
||||
add: 'Tambah',
|
||||
duplicateResolution: 'Resolusi ini sudah ada.',
|
||||
|
||||
@@ -75,9 +75,10 @@ const it = {
|
||||
autoWarning:
|
||||
"La calibrazione potrebbe non riuscire se l'applicazione utente ha uno sfondo completamente nero.",
|
||||
manual: 'Manuale',
|
||||
selectedResolution: 'Risoluzione area selezionata',
|
||||
unused: 'Non utilizzata',
|
||||
originalResolution: 'Risoluzione originale',
|
||||
selectResolution: 'Seleziona la risoluzione originale',
|
||||
useSelectedArea: "Usa la risoluzione dell'area selezionata",
|
||||
addResolution: 'Aggiungi una risoluzione personalizzata',
|
||||
add: 'Aggiungi',
|
||||
duplicateResolution: 'Questa risoluzione esiste già.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const ja = {
|
||||
autoWarning:
|
||||
'ユーザーアプリケーションの背景が完全な黒の場合、補正に失敗することがあります。',
|
||||
manual: '手動',
|
||||
selectedResolution: '選択領域の解像度',
|
||||
unused: '未使用',
|
||||
originalResolution: '元の解像度',
|
||||
selectResolution: '元の解像度を選択',
|
||||
useSelectedArea: '選択領域の解像度を使用',
|
||||
addResolution: 'カスタム解像度を追加',
|
||||
add: '追加',
|
||||
duplicateResolution: 'この解像度はすでに存在します。',
|
||||
|
||||
@@ -71,9 +71,10 @@ const ko = {
|
||||
auto: '자동',
|
||||
autoWarning: '사용자 애플리케이션의 배경이 완전히 검은색이면 보정에 실패할 수 있습니다.',
|
||||
manual: '수동',
|
||||
selectedResolution: '선택 영역 해상도',
|
||||
unused: '사용 안 함',
|
||||
originalResolution: '원본 해상도',
|
||||
selectResolution: '원본 해상도 선택',
|
||||
useSelectedArea: '선택한 영역의 해상도 사용',
|
||||
addResolution: '사용자 지정 해상도 추가',
|
||||
add: '추가',
|
||||
duplicateResolution: '이미 존재하는 해상도입니다.',
|
||||
|
||||
@@ -74,9 +74,10 @@ const nb = {
|
||||
autoWarning:
|
||||
'Kalibreringen kan mislykkes hvis brukerprogrammet har en helt svart bakgrunn.',
|
||||
manual: 'Manuell',
|
||||
selectedResolution: 'Oppløsning for valgt område',
|
||||
unused: 'Ikke i bruk',
|
||||
originalResolution: 'Opprinnelig oppløsning',
|
||||
selectResolution: 'Velg opprinnelig oppløsning',
|
||||
useSelectedArea: 'Bruk oppløsningen til det valgte området',
|
||||
addResolution: 'Legg til egendefinert oppløsning',
|
||||
add: 'Legg til',
|
||||
duplicateResolution: 'Denne oppløsningen finnes allerede.',
|
||||
|
||||
@@ -75,9 +75,10 @@ const nl = {
|
||||
autoWarning:
|
||||
'De kalibratie kan mislukken als de gebruikerstoepassing een volledig zwarte achtergrond heeft.',
|
||||
manual: 'Handmatig',
|
||||
selectedResolution: 'Resolutie van geselecteerd gebied',
|
||||
unused: 'Niet gebruikt',
|
||||
originalResolution: 'Oorspronkelijke resolutie',
|
||||
selectResolution: 'Oorspronkelijke resolutie selecteren',
|
||||
useSelectedArea: 'Resolutie van geselecteerd gebied gebruiken',
|
||||
addResolution: 'Aangepaste resolutie toevoegen',
|
||||
add: 'Toevoegen',
|
||||
duplicateResolution: 'Deze resolutie bestaat al.',
|
||||
|
||||
@@ -74,9 +74,10 @@ const pl = {
|
||||
autoWarning:
|
||||
'Kalibracja może się nie powieść, gdy aplikacja użytkownika ma całkowicie czarne tło.',
|
||||
manual: 'Ręczna',
|
||||
selectedResolution: 'Rozdzielczość zaznaczonego obszaru',
|
||||
unused: 'Nieużywane',
|
||||
originalResolution: 'Oryginalna rozdzielczość',
|
||||
selectResolution: 'Wybierz oryginalną rozdzielczość',
|
||||
useSelectedArea: 'Użyj rozdzielczości zaznaczonego obszaru',
|
||||
addResolution: 'Dodaj niestandardową rozdzielczość',
|
||||
add: 'Dodaj',
|
||||
duplicateResolution: 'Ta rozdzielczość już istnieje.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const pt_br = {
|
||||
autoWarning:
|
||||
'A calibração pode falhar se o aplicativo do usuário tiver um fundo totalmente preto.',
|
||||
manual: 'Manual',
|
||||
selectedResolution: 'Resolução da área selecionada',
|
||||
unused: 'Não utilizado',
|
||||
originalResolution: 'Resolução original',
|
||||
selectResolution: 'Selecionar resolução original',
|
||||
useSelectedArea: 'Usar a resolução da área selecionada',
|
||||
addResolution: 'Adicionar resolução personalizada',
|
||||
add: 'Adicionar',
|
||||
duplicateResolution: 'Esta resolução já existe.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const ru = {
|
||||
autoWarning:
|
||||
'Калибровка может завершиться неудачей, если фон пользовательского приложения полностью чёрный.',
|
||||
manual: 'Вручную',
|
||||
selectedResolution: 'Разрешение выбранной области',
|
||||
unused: 'Не используется',
|
||||
originalResolution: 'Исходное разрешение',
|
||||
selectResolution: 'Выберите исходное разрешение',
|
||||
useSelectedArea: 'Использовать разрешение выбранной области',
|
||||
addResolution: 'Добавить пользовательское разрешение',
|
||||
add: 'Добавить',
|
||||
duplicateResolution: 'Такое разрешение уже существует.',
|
||||
|
||||
@@ -70,9 +70,10 @@ const se = {
|
||||
auto: 'Automatisk',
|
||||
autoWarning: 'Kalibreringen kan misslyckas om användarprogrammet har en helsvart bakgrund.',
|
||||
manual: 'Manuell',
|
||||
selectedResolution: 'Valt områdesupplösning',
|
||||
unused: 'Används inte',
|
||||
originalResolution: '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',
|
||||
add: 'Lägg till',
|
||||
duplicateResolution: 'Den här upplösningen finns redan.',
|
||||
|
||||
@@ -70,9 +70,10 @@ const th = {
|
||||
auto: 'อัตโนมัติ',
|
||||
autoWarning: 'การปรับเทียบอาจล้มเหลวเมื่อแอปพลิเคชันของผู้ใช้มีพื้นหลังสีดำสนิท',
|
||||
manual: 'กำหนดเอง',
|
||||
selectedResolution: 'ความละเอียดพื้นที่ที่เลือก',
|
||||
unused: 'ไม่ได้ใช้',
|
||||
originalResolution: 'ความละเอียดต้นฉบับ',
|
||||
selectResolution: 'เลือกความละเอียดต้นฉบับ',
|
||||
useSelectedArea: 'ใช้ความละเอียดของพื้นที่ที่เลือก',
|
||||
addResolution: 'เพิ่มความละเอียดแบบกำหนดเอง',
|
||||
add: 'เพิ่ม',
|
||||
duplicateResolution: 'มีความละเอียดนี้อยู่แล้ว',
|
||||
|
||||
@@ -74,9 +74,10 @@ const tr = {
|
||||
autoWarning:
|
||||
'Kullanıcı uygulamasının arka planı tamamen siyah olduğunda kalibrasyon başarısız olabilir.',
|
||||
manual: 'Manuel',
|
||||
selectedResolution: 'Seçili Alan Çözünürlüğü',
|
||||
unused: 'Kullanılmıyor',
|
||||
originalResolution: 'Orijinal Çözünürlük',
|
||||
selectResolution: 'Orijinal çözünürlüğü seçin',
|
||||
useSelectedArea: 'Seçili alanın çözünürlüğünü kullan',
|
||||
addResolution: 'Özel çözünürlük ekle',
|
||||
add: 'Ekle',
|
||||
duplicateResolution: 'Bu çözünürlük zaten mevcut.',
|
||||
|
||||
@@ -74,9 +74,10 @@ const uk = {
|
||||
autoWarning:
|
||||
'Калібрування може завершитися невдало, якщо користувацька програма має повністю чорне тло.',
|
||||
manual: 'Вручну',
|
||||
selectedResolution: 'Роздільна здатність вибраної області',
|
||||
unused: 'Не використовується',
|
||||
originalResolution: 'Початкова роздільна здатність',
|
||||
selectResolution: 'Виберіть початкову роздільну здатність',
|
||||
useSelectedArea: 'Використати роздільну здатність вибраної області',
|
||||
addResolution: 'Додати власну роздільну здатність',
|
||||
add: 'Додати',
|
||||
duplicateResolution: 'Така роздільна здатність уже існує.',
|
||||
|
||||
@@ -73,9 +73,10 @@ const vi = {
|
||||
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.',
|
||||
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',
|
||||
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',
|
||||
add: 'Thêm',
|
||||
duplicateResolution: 'Độ phân giải này đã tồn tại.',
|
||||
|
||||
@@ -72,9 +72,10 @@ const zh = {
|
||||
auto: '自动',
|
||||
autoWarning: '若用户程序为纯黑色背景,则可能校准出错。',
|
||||
manual: '手动',
|
||||
selectedResolution: '框选分辨率',
|
||||
unused: '未使用',
|
||||
originalResolution: '原始分辨率',
|
||||
selectResolution: '选择原始分辨率',
|
||||
useSelectedArea: '使用框选分辨率',
|
||||
addResolution: '添加自定义分辨率',
|
||||
add: '添加',
|
||||
duplicateResolution: '该分辨率已存在。',
|
||||
|
||||
@@ -69,9 +69,10 @@ const zh_tw = {
|
||||
auto: '自動',
|
||||
autoWarning: '當使用者應用程式使用全黑背景時,校正可能會失敗。',
|
||||
manual: '手動',
|
||||
selectedResolution: '框選解析度',
|
||||
unused: '未使用',
|
||||
originalResolution: '原始解析度',
|
||||
selectResolution: '選擇原始解析度',
|
||||
useSelectedArea: '使用所選區域的解析度',
|
||||
addResolution: '新增自訂解析度',
|
||||
add: '新增',
|
||||
duplicateResolution: '此解析度已存在。',
|
||||
|
||||
@@ -18,6 +18,8 @@ export const resolutionAtom = atom<Resolution | null>(null);
|
||||
// currently effective absolute mouse input region
|
||||
export const inputRegionAtom = 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>('');
|
||||
|
||||
// device-level control region mode; disabled by default
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
manualInputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
resolutionAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom,
|
||||
videoModeAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
@@ -42,14 +44,11 @@ function getVideoMode() {
|
||||
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
|
||||
|
||||
const cookieVideoMode = storage.getVideoMode();
|
||||
if (cookieVideoMode) {
|
||||
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
return cookieVideoMode;
|
||||
if (!cookieVideoMode || (cookieVideoMode === 'direct' && !window.VideoDecoder)) {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
|
||||
return defaultVideoMode;
|
||||
return ['direct', 'h264', 'mjpeg'].includes(cookieVideoMode) ? cookieVideoMode : defaultVideoMode;
|
||||
}
|
||||
|
||||
export const Desktop = () => {
|
||||
@@ -64,6 +63,8 @@ export const Desktop = () => {
|
||||
const [inputRegion, setInputRegion] = useAtom(inputRegionAtom);
|
||||
const [controlRegionMode, setControlRegionModeState] = useAtom(controlRegionModeAtom);
|
||||
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
|
||||
const setManualRegions = useSetAtom(manualRegionsAtom);
|
||||
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
|
||||
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
|
||||
const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom);
|
||||
|
||||
@@ -76,6 +77,8 @@ export const Desktop = () => {
|
||||
setResolution(res);
|
||||
setInputRegion(null);
|
||||
setManualInputRegion(null);
|
||||
setManualRegions([]);
|
||||
setSelectedManualRegion('');
|
||||
setSelectedOriginalResolution('');
|
||||
setControlRegionModeState('off');
|
||||
|
||||
@@ -87,15 +90,24 @@ export const Desktop = () => {
|
||||
? (config as InputRegion)
|
||||
: null;
|
||||
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);
|
||||
setManualRegions(regions);
|
||||
setSelectedManualRegion(selectedRegion);
|
||||
setSelectedOriginalResolution(selectedResolution);
|
||||
setInputRegion(mode === 'manual' && !selectedResolution ? manualRegion : null);
|
||||
setInputRegion(mode === 'manual' && selectedRegion ? selectedManualRegion || null : null);
|
||||
setControlRegionModeState(mode);
|
||||
})
|
||||
.catch(() => {
|
||||
setControlRegionModeState('off');
|
||||
setInputRegion(null);
|
||||
setManualInputRegion(null);
|
||||
setManualRegions([]);
|
||||
setSelectedManualRegion('');
|
||||
setSelectedOriginalResolution('');
|
||||
});
|
||||
|
||||
@@ -107,7 +119,9 @@ export const Desktop = () => {
|
||||
setControlRegionModeState,
|
||||
setInputRegion,
|
||||
setManualInputRegion,
|
||||
setManualRegions,
|
||||
setResolution,
|
||||
setSelectedManualRegion,
|
||||
setSelectedOriginalResolution,
|
||||
setVideoMode
|
||||
]);
|
||||
@@ -135,7 +149,7 @@ export const Desktop = () => {
|
||||
clearTimeout(validationTimer);
|
||||
}
|
||||
validationTimer = setTimeout(() => {
|
||||
const mediaSize = getMediaSize(target);
|
||||
const mediaSize = getMediaSize(target, resolution);
|
||||
if (!mediaSize || !isMediaReady(target) || isInputRegionCompatible(region, mediaSize)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,3 +11,23 @@ export const BitRateMap = new Map([
|
||||
[3, 2000],
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as ls from '@/lib/localstorage';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
import { getQualityMap, getScreenType } from './constants.ts';
|
||||
import { Fps } from './fps';
|
||||
import { FrameDetect } from './frame-detect';
|
||||
import { Gop } from './gop.tsx';
|
||||
@@ -23,12 +23,16 @@ export const Screen = () => {
|
||||
|
||||
const videoMode = useAtomValue(videoModeAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const isMjpeg = videoMode === 'mjpeg';
|
||||
const [fps, setFps] = useState(30);
|
||||
const [quality, setQuality] = useState(2);
|
||||
const [gop, setGop] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
updateScreen('type', videoMode === 'mjpeg' ? 0 : 1);
|
||||
const screenType = getScreenType(videoMode);
|
||||
if (screenType !== null) {
|
||||
updateScreen('type', screenType);
|
||||
}
|
||||
updateScreen('resolution', resolution!.height);
|
||||
updateQuality();
|
||||
updateFps();
|
||||
@@ -40,7 +44,9 @@ export const Screen = () => {
|
||||
if (!cookieQuality) return;
|
||||
|
||||
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);
|
||||
if (rsp.code === 0) {
|
||||
@@ -75,8 +81,8 @@ export const Screen = () => {
|
||||
<Quality quality={quality} setQuality={setQuality} />
|
||||
<Fps fps={fps} setFps={setFps} />
|
||||
<Scale />
|
||||
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />}
|
||||
{videoMode === 'mjpeg' && <FrameDetect />}
|
||||
{!isMjpeg && <Gop gop={gop} setGop={setGop} />}
|
||||
{isMjpeg && <FrameDetect />}
|
||||
<Reset />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
Tooltip,
|
||||
Typography
|
||||
} from 'antd';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { ScanSearchIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
getInputRegion,
|
||||
setManualRegions as saveManualRegions,
|
||||
setControlRegionMode,
|
||||
setOriginalResolutionConfig,
|
||||
setSelectedOriginalResolution
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom,
|
||||
manualInputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
import { menuCloseSignalAtom } from '@/jotai/settings.ts';
|
||||
@@ -44,9 +47,12 @@ const resolutionKey = ({ width, height }: ResolutionPreset) => `${width}x${heigh
|
||||
export const OriginalResolution = () => {
|
||||
const { t } = useTranslation();
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
const manualInputRegion = useAtomValue(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 setSelecting = useSetAtom(inputRegionSelectingAtom);
|
||||
const setKeyboardLock = useSetAtom(keyboardLockAtom);
|
||||
@@ -74,7 +80,9 @@ export const OriginalResolution = () => {
|
||||
if (rsp.code === 0) {
|
||||
const config = rsp.data as ControlRegionConfig;
|
||||
setResolutions(config?.resolutions || []);
|
||||
setSelectedResolution(config?.selectedResolution || '');
|
||||
setManualRegions(config?.regions || []);
|
||||
setSelectedManualRegion(config?.selectedRegion || '');
|
||||
setSelectedOriginalResolutionState(config?.selectedResolution || '');
|
||||
setManualInputRegion(
|
||||
isValidInputRegion(config as InputRegion) ? (config as InputRegion) : null
|
||||
);
|
||||
@@ -82,18 +90,41 @@ export const OriginalResolution = () => {
|
||||
return rsp;
|
||||
}
|
||||
|
||||
async function applyResolution(key: string) {
|
||||
async function applyOriginalResolution(key: string) {
|
||||
const rsp = await setSelectedOriginalResolution(key);
|
||||
if (rsp.code !== 0) {
|
||||
messageApi.error(t('screen.controlRegion.saveFailed'));
|
||||
return;
|
||||
}
|
||||
setSelectedResolution(key);
|
||||
setSelectedOriginalResolutionState(key);
|
||||
setSelectedManualRegion('');
|
||||
if (!key) setInputRegion(null);
|
||||
setIsPopoverOpen(true);
|
||||
}
|
||||
|
||||
async function saveResolutions(next: ResolutionPreset[], nextSelected = selectedResolution) {
|
||||
const rsp = await setOriginalResolutionConfig(next, nextSelected);
|
||||
async function applyManualRegion(key: string) {
|
||||
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) {
|
||||
messageApi.error(t('screen.controlRegion.saveFailed'));
|
||||
return false;
|
||||
@@ -122,7 +153,7 @@ export const OriginalResolution = () => {
|
||||
async function deleteResolution(index: number) {
|
||||
if (index < 0 || index >= resolutions.length) return;
|
||||
const deleted = resolutionKey(resolutions[index]);
|
||||
const nextSelected = selectedResolution === deleted ? '' : selectedResolution;
|
||||
const nextSelected = selectedOriginalResolution === deleted ? '' : selectedOriginalResolution;
|
||||
if (
|
||||
await saveResolutions(
|
||||
resolutions.filter((_, itemIndex) => itemIndex !== index),
|
||||
@@ -130,12 +161,29 @@ export const OriginalResolution = () => {
|
||||
)
|
||||
) {
|
||||
if (!nextSelected) {
|
||||
setSelectedResolution('');
|
||||
setInputRegion(manualInputRegion);
|
||||
setSelectedOriginalResolutionState('');
|
||||
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) {
|
||||
const rsp = await setControlRegionMode(nextMode);
|
||||
if (rsp.code !== 0) {
|
||||
@@ -148,9 +196,14 @@ export const OriginalResolution = () => {
|
||||
const manualRegion = isValidInputRegion(config.data as InputRegion)
|
||||
? (config.data as InputRegion)
|
||||
: null;
|
||||
const selectedRegion = (config.data as ControlRegionConfig)?.selectedRegion || '';
|
||||
const selectedResolution = (config.data as ControlRegionConfig)?.selectedResolution || '';
|
||||
const regions = (config.data as ControlRegionConfig)?.regions || [];
|
||||
setManualInputRegion(manualRegion);
|
||||
setInputRegion(
|
||||
(config.data as ControlRegionConfig)?.selectedResolution ? null : manualRegion
|
||||
selectedResolution
|
||||
? null
|
||||
: regions.find((region) => `${region.width}x${region.height}` === selectedRegion) || null
|
||||
);
|
||||
} else {
|
||||
setInputRegion(null);
|
||||
@@ -195,19 +248,58 @@ export const OriginalResolution = () => {
|
||||
<Button block type="primary" icon={<ScanSearchIcon size={14} />} onClick={selectArea}>
|
||||
{t('screen.controlRegion.select')}
|
||||
</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>
|
||||
<Space.Compact block>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedResolution}
|
||||
value={selectedOriginalResolution}
|
||||
placeholder={t('screen.controlRegion.selectResolution')}
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
options={resolutions
|
||||
.map((resolution) => ({
|
||||
options={[
|
||||
{ label: t('screen.controlRegion.unused'), value: '' },
|
||||
...resolutions.map((resolution) => ({
|
||||
label: resolutionKey(resolution),
|
||||
value: resolutionKey(resolution)
|
||||
}))
|
||||
.concat([{ label: t('screen.controlRegion.useSelectedArea'), value: '' }])}
|
||||
]}
|
||||
optionRender={(option) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.label}</span>
|
||||
@@ -233,7 +325,7 @@ export const OriginalResolution = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onChange={applyResolution}
|
||||
onChange={applyOriginalResolution}
|
||||
/>
|
||||
<Tooltip title={t('screen.controlRegion.addResolution')}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setIsAddOpen(true)} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { updateScreen } from '@/api/vm';
|
||||
import { setQuality as setCookie } from '@/lib/localstorage.ts';
|
||||
import { videoModeAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { BitRateMap, QualityMap } from './constants.ts';
|
||||
import { getQualityMap } from './constants.ts';
|
||||
|
||||
type QualityProps = {
|
||||
quality: number;
|
||||
@@ -26,7 +26,11 @@ export const Quality = ({ quality, setQuality }: QualityProps) => {
|
||||
];
|
||||
|
||||
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);
|
||||
if (rsp.code !== 0) {
|
||||
|
||||
@@ -4,14 +4,16 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom
|
||||
inputRegionSelectingAtom,
|
||||
resolutionAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
import { detectFrameContent, getMediaSize, isMediaReady } from './geometry.ts';
|
||||
import { detectFrameContent, getMediaSize } from './geometry.ts';
|
||||
|
||||
export const AutoRegion = () => {
|
||||
const mode = useAtomValue(controlRegionModeAtom);
|
||||
const selecting = useAtomValue(inputRegionSelectingAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,18 +21,25 @@ export const AutoRegion = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen) return;
|
||||
const target = screen;
|
||||
let stopped = false;
|
||||
let target: Element | null = null;
|
||||
let candidate = '';
|
||||
let confirmations = 0;
|
||||
|
||||
const detect = () => {
|
||||
if (stopped || !isMediaReady(target)) return;
|
||||
const mediaSize = getMediaSize(target);
|
||||
if (stopped) return;
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen) return;
|
||||
if (screen !== target) {
|
||||
target = screen;
|
||||
candidate = '';
|
||||
confirmations = 0;
|
||||
}
|
||||
|
||||
const mediaSize = getMediaSize(screen, resolution);
|
||||
if (!mediaSize) return;
|
||||
const content = detectFrameContent(target, mediaSize);
|
||||
const content = detectFrameContent(screen, mediaSize);
|
||||
const key = [
|
||||
mediaSize.width,
|
||||
mediaSize.height,
|
||||
@@ -65,7 +74,7 @@ export const AutoRegion = () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mode, selecting, setInputRegion]);
|
||||
}, [mode, resolution, selecting, setInputRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto') setInputRegion(null);
|
||||
|
||||
@@ -15,6 +15,10 @@ export type FrameContent = {
|
||||
export type RenderedMediaRect = FrameContent;
|
||||
|
||||
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 => ({
|
||||
left: 0,
|
||||
@@ -23,7 +27,13 @@ export const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLCanvasElement) {
|
||||
const width = Number(screen.dataset.mediaWidth);
|
||||
const height = Number(screen.dataset.mediaHeight);
|
||||
if (width > 0 && height > 0) {
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null;
|
||||
}
|
||||
|
||||
export function isMediaReady(screen: Element) {
|
||||
if (screen instanceof HTMLVideoElement) {
|
||||
return screen.readyState >= 2 && screen.videoWidth > 0 && screen.videoHeight > 0;
|
||||
return screen.readyState >= 2 && !!getMediaSize(screen);
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLImageElement) {
|
||||
return screen.complete && screen.naturalWidth > 0 && screen.naturalHeight > 0;
|
||||
return !!getMediaSize(screen);
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLCanvasElement) {
|
||||
return Number(screen.dataset.mediaWidth) > 0 && Number(screen.dataset.mediaHeight) > 0;
|
||||
return !!getMediaSize(screen);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -73,20 +75,33 @@ export function detectFrameContent(screen: Element, mediaSize: MediaSize): Frame
|
||||
|
||||
try {
|
||||
const pixels = context.getImageData(0, 0, width, height).data;
|
||||
const left = findBorderInset(pixels, width, height, true, 'horizontal');
|
||||
const right = findBorderInset(pixels, width, height, false, 'horizontal');
|
||||
const top = findBorderInset(pixels, width, height, true, 'vertical');
|
||||
const bottom = findBorderInset(pixels, width, height, false, 'vertical');
|
||||
const horizontalBorder =
|
||||
left >= Math.max(4, Math.round(width * 0.02)) &&
|
||||
right >= Math.max(4, Math.round(width * 0.02)) &&
|
||||
left + right < width * 0.4 &&
|
||||
Math.abs(left - right) <= Math.max(4, Math.round(width * 0.05));
|
||||
const verticalBorder =
|
||||
top >= Math.max(4, Math.round(height * 0.02)) &&
|
||||
bottom >= Math.max(4, Math.round(height * 0.02)) &&
|
||||
top + bottom < height * 0.4 &&
|
||||
Math.abs(top - bottom) <= Math.max(4, Math.round(height * 0.05));
|
||||
let top = findBorderInset(pixels, width, height, true, 'vertical');
|
||||
let bottom = findBorderInset(pixels, width, height, false, 'vertical');
|
||||
let verticalBorder = isSymmetricBorder(top, bottom, height);
|
||||
const left = findBorderInset(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
true,
|
||||
'horizontal',
|
||||
verticalBorder ? top : 0,
|
||||
verticalBorder ? height - bottom : height
|
||||
);
|
||||
const right = findBorderInset(
|
||||
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 frameRight = horizontalBorder ? (right / width) * mediaSize.width : 0;
|
||||
const frameTop = verticalBorder ? (top / height) * mediaSize.height : 0;
|
||||
@@ -128,28 +143,43 @@ function findBorderInset(
|
||||
width: number,
|
||||
height: number,
|
||||
fromStart: boolean,
|
||||
axis: 'horizontal' | 'vertical'
|
||||
axis: 'horizontal' | 'vertical',
|
||||
sampleStart = 0,
|
||||
sampleEnd = axis === 'horizontal' ? height : width
|
||||
) {
|
||||
const scanSize = axis === 'horizontal' ? width : height;
|
||||
const sampleSize = axis === 'horizontal' ? height : width;
|
||||
const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map((ratio) =>
|
||||
Math.round((sampleSize - 1) * ratio)
|
||||
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) => sampleStart + Math.round((sampleSize - 1) * ratio)
|
||||
);
|
||||
const limit = Math.floor(scanSize * 0.45);
|
||||
let inset = 0;
|
||||
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 y = axis === 'horizontal' ? line : fromStart ? offset : height - offset - 1;
|
||||
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;
|
||||
inset = offset + 1;
|
||||
}
|
||||
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(
|
||||
elementRect: DOMRect,
|
||||
mediaSize: MediaSize
|
||||
|
||||
@@ -17,5 +17,9 @@ export const Screen = () => {
|
||||
return <H264Direct />;
|
||||
}
|
||||
|
||||
return <H264Webrtc />;
|
||||
if (videoMode === 'h264') {
|
||||
return <H264Webrtc />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
inputRegionAtom,
|
||||
inputRegionSelectingAtom,
|
||||
manualInputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
resolutionAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom,
|
||||
videoScaleAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
@@ -75,6 +78,7 @@ const resizeHandles: Array<{
|
||||
];
|
||||
|
||||
const minimumSelectionSize = 4;
|
||||
const edgeSnapDistance = 8;
|
||||
|
||||
export const InputRegionOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -82,6 +86,9 @@ export const InputRegionOverlay = () => {
|
||||
const [selecting, setSelecting] = useAtom(inputRegionSelectingAtom);
|
||||
const [, setInputRegionState] = useAtom(inputRegionAtom);
|
||||
const setManualInputRegion = useSetAtom(manualInputRegionAtom);
|
||||
const [manualRegions, setManualRegions] = useAtom(manualRegionsAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const setSelectedManualRegion = useSetAtom(selectedManualRegionAtom);
|
||||
const setSelectedOriginalResolution = useSetAtom(selectedOriginalResolutionAtom);
|
||||
const videoScale = useAtomValue(videoScaleAtom);
|
||||
const setVideoScale = useSetAtom(videoScaleAtom);
|
||||
@@ -151,17 +158,15 @@ export const InputRegionOverlay = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen) {
|
||||
setFrameRect(null);
|
||||
setMediaSize(null);
|
||||
frameRectRef.current = null;
|
||||
return;
|
||||
}
|
||||
const target = screen;
|
||||
|
||||
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();
|
||||
if (!nextMediaSize || bounds.width <= 0 || bounds.height <= 0) {
|
||||
setFrameRect(null);
|
||||
@@ -192,31 +197,16 @@ export const InputRegionOverlay = () => {
|
||||
}
|
||||
|
||||
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('scroll', updateFrame, true);
|
||||
target.addEventListener('load', updateFrame);
|
||||
target.addEventListener('loadedmetadata', updateFrame);
|
||||
target.addEventListener('canplay', updateFrame);
|
||||
target.addEventListener('resize', updateFrame);
|
||||
const timer = window.setInterval(updateFrame, 250);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
window.removeEventListener('resize', updateFrame);
|
||||
window.removeEventListener('scroll', updateFrame, true);
|
||||
target.removeEventListener('load', updateFrame);
|
||||
target.removeEventListener('loadedmetadata', updateFrame);
|
||||
target.removeEventListener('canplay', updateFrame);
|
||||
target.removeEventListener('resize', updateFrame);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [selecting, videoScale]);
|
||||
}, [resolution, selecting, videoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecting) {
|
||||
@@ -345,12 +335,20 @@ export const InputRegionOverlay = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const right = frameRect.left + frameRect.width;
|
||||
const bottom = frameRect.top + frameRect.height;
|
||||
return {
|
||||
x: Math.max(frameRect.left, Math.min(frameRect.left + frameRect.width, x)),
|
||||
y: Math.max(frameRect.top, Math.min(frameRect.top + frameRect.height, y))
|
||||
x: snapToEdge(Math.max(frameRect.left, Math.min(right, x)), frameRect.left, right),
|
||||
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) {
|
||||
const start = dragStartRef.current;
|
||||
const point = clampPoint(x, y);
|
||||
@@ -448,17 +446,19 @@ export const InputRegionOverlay = () => {
|
||||
setCursorPoint(point);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||
if (
|
||||
!frameRect ||
|
||||
event.clientX < frameRect.left ||
|
||||
event.clientX > frameRect.left + frameRect.width ||
|
||||
event.clientY < frameRect.top ||
|
||||
event.clientY > frameRect.top + frameRect.height
|
||||
) {
|
||||
function updateHoverCursorPoint(x: number, y: number) {
|
||||
if (!frameRect || selection) {
|
||||
updateCursorPoint(x, y);
|
||||
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);
|
||||
if (!point) {
|
||||
return;
|
||||
@@ -480,11 +480,14 @@ export const InputRegionOverlay = () => {
|
||||
setCursorPoint(null);
|
||||
} else if (resizeRef.current) {
|
||||
updateResizedSelection(event.clientX, event.clientY);
|
||||
} else {
|
||||
updateCursorPoint(event.clientX, event.clientY, dragging);
|
||||
if (dragging) {
|
||||
updateSelection(event.clientX, event.clientY);
|
||||
} else if (dragging) {
|
||||
const point = clampPoint(event.clientX, event.clientY);
|
||||
if (point) {
|
||||
updateCursorPoint(point.x, point.y, true);
|
||||
updateSelection(point.x, point.y);
|
||||
}
|
||||
} else {
|
||||
updateHoverCursorPoint(event.clientX, event.clientY);
|
||||
}
|
||||
if (moveRef.current) {
|
||||
updateMovedSelection(event.clientX, event.clientY);
|
||||
@@ -575,14 +578,20 @@ export const InputRegionOverlay = () => {
|
||||
width: Math.max(1, right - left),
|
||||
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) {
|
||||
setError(t('screen.controlRegion.saveFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setManualInputRegion(region);
|
||||
setManualRegions(regions);
|
||||
setSelectedManualRegion(key);
|
||||
setSelectedOriginalResolution('');
|
||||
setInputRegionState(region);
|
||||
cursorPointRef.current = null;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
controlRegionModeAtom,
|
||||
inputRegionAtom,
|
||||
manualInputRegionAtom,
|
||||
manualRegionsAtom,
|
||||
resolutionAtom,
|
||||
selectedManualRegionAtom,
|
||||
selectedOriginalResolutionAtom
|
||||
} from '@/jotai/screen.ts';
|
||||
|
||||
@@ -12,52 +14,47 @@ import { getCenteredInputRegionByAspectRatio, getMediaSize } from './geometry.ts
|
||||
|
||||
export const ManualRegion = () => {
|
||||
const mode = useAtomValue(controlRegionModeAtom);
|
||||
const selected = useAtomValue(selectedOriginalResolutionAtom);
|
||||
const manualRegion = useAtomValue(manualInputRegionAtom);
|
||||
const selectedOriginal = useAtomValue(selectedOriginalResolutionAtom);
|
||||
const selectedManual = useAtomValue(selectedManualRegionAtom);
|
||||
const manualRegions = useAtomValue(manualRegionsAtom);
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const setInputRegion = useSetAtom(inputRegionAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'manual') return;
|
||||
if (!selected) {
|
||||
setInputRegion(manualRegion);
|
||||
if (selectedManual) {
|
||||
setInputRegion(
|
||||
manualRegions.find((region) => `${region.width}x${region.height}` === selectedManual) ||
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [width, height] = selected.split('x').map(Number);
|
||||
if (manualRegion?.width === width && manualRegion.height === height) {
|
||||
setInputRegion(manualRegion);
|
||||
return;
|
||||
}
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen || !width || !height) {
|
||||
if (!selectedOriginal) {
|
||||
setInputRegion(null);
|
||||
return;
|
||||
}
|
||||
const target = screen;
|
||||
|
||||
const [width, height] = selectedOriginal.split('x').map(Number);
|
||||
if (!width || !height) {
|
||||
setInputRegion(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const mediaSize = getMediaSize(target);
|
||||
const target = document.getElementById('screen');
|
||||
if (!target) return;
|
||||
const mediaSize = getMediaSize(target, resolution);
|
||||
setInputRegion(
|
||||
mediaSize ? getCenteredInputRegionByAspectRatio(width, height, mediaSize) : null
|
||||
);
|
||||
};
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
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);
|
||||
const timer = window.setInterval(update, 250);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
target.removeEventListener('load', update);
|
||||
target.removeEventListener('loadedmetadata', update);
|
||||
target.removeEventListener('canplay', update);
|
||||
target.removeEventListener('resize', update);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [manualRegion, mode, selected, setInputRegion]);
|
||||
}, [manualRegions, mode, resolution, selectedManual, selectedOriginal, setInputRegion]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -20,4 +20,6 @@ export type ControlRegionConfig = Partial<InputRegion> & {
|
||||
mode: ControlRegionMode;
|
||||
resolutions?: OriginalResolution[];
|
||||
selectedResolution?: string;
|
||||
regions?: InputRegion[];
|
||||
selectedRegion?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user