Featured image of post Universal Cup Finals 2025 技术总结

Universal Cup Finals 2025 技术总结

非常荣幸能作为本次 Universal Cup Finals 2025 的技术组成员之一,为这次比赛的顺利进行贡献了一份力量。

信息

本文仍在施工中……

在这次比赛中,我们的技术组主要负责了选手机镜像的制作、比赛现场的服务器搭建、比赛直播的技术支持等工作。 在这篇文章中,我将对这次比赛中我参与的工作进行一些复盘。实际上是流水账,想到哪里写到哪里。

选手机镜像制作

这部分工作是我们在比赛前期进行的,选手的镜像基于 Ubuntu 24.04 LTS。 我们需要在这个基础上进行一些定制化的工作,包括安装必要的编程软件、配置好远程登录、以及预先配置好一些常用的脚本等。

选手机的镜像基于 ICPC World Finals 2025 的官方镜像(草稿),我在这个基础上进行了一些修改。

首先是 Ubuntu 24.04 LTS 的安装方式不再是使用 ubiquity 安装,因此直接解压 squashfs 文件进行修改是不可行的。 所以今年的镜像直接给的是 img 文件,这个文件是一个完整的磁盘镜像。 ICPC 官网给出的刻盘方式是直接写入 U 盘后启动就可以直接使用的。定制化工作也是直接在启动的系统中直接进行。

因此我直接使用 qemu-system 启动这个镜像,然后进行修改。为了方便维护所有的修改,我将修改的所有步骤都编写成了 ansible playbook ,这样可以方便地重复使用。

参考步骤
bash
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# create a rootfs mount point
mkdir -p tmp/rootfs
offset=$(parted -s "$IMGFILE" unit B print | grep ext4 | awk '{print $2}' | sed 's/B//')
sudo mount -o loop,offset="$offset" "$IMGFILE" tmp/rootfs

# copy authorized_keys
echo "ssh-ed25519 AAAAC3N..." | sudo tee tmp/rootfs/root/.ssh/authorized_keys2
sudo touch tmp/rootfs/root/.hushlogin

# unmount the image rootfs
sudo umount tmp/rootfs

qemu-system-x86_64 -smp 2 -m 4096 -drive file="$IMGFILE",index=0,media=disk,format=raw \
    --enable-kvm -net user,hostfwd=tcp::$SSHPORT-:22 -net nic --daemonize --pidfile $PIDFILE \
    -vnc :0 -vga qxl -spice port=5901,disable-ticketing=on -usbdevice tablet

waitforssh

echo "Running ansible"
INVENTORY_FILE=$(mktemp)
cat <<EOF > "$INVENTORY_FILE"
vm ansible_port=$SSHPORT ansible_host=127.0.0.1 ansible_user=root
EOF

ANSIBLE_HOST_KEY_CHECKING=False time ansible-playbook \
    -i "$INVENTORY_FILE" --ssh-extra-args="-o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
    --diff --private-key $SSH_BUILD_KEY main.yml
rm -f "$INVENTORY_FILE"

值得记录的点

卸载 Network Manager,使用 systemd-networkd 配网;

Firefox Policy 可以直接给一些网站允许通知的权限:

json
{
  "policies": {
    "Permissions": {
      "Notifications": {
        "Allow": [
          "https://finals.ucup.ac/"
        ]
      }
    }
  }
}

禁用键盘上的睡眠键:

yaml
- name: Update systemd-sleep drop-in
  ansible.builtin.copy:
    dest: "/etc/systemd/sleep.conf.d/do-not-suspend.conf"
    owner: root
    group: root
    mode: '0644'
    content: |
      [Sleep]
      AllowSuspend=no
      AllowHibernation=no
      AllowSuspendThenHibernate=no
      AllowHybridSleep=no

默认禁用一些 VSCode 插件:

shell
$ sqlite3 state.vscdb
SQLite version 3.49.1 2025-02-18 13:38:58
Enter ".help" for usage hints.
sqlite> CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);
sqlite> INSERT INTO ItemTable VALUES ('extensionsIdentifiers/disabled','[{"id":"vscodevim.vim","uuid":"d96e79c6-8b25-4be3-8545-0e0ecefcae03"},{"id": "ms-vscode.sublime-keybindings-4.1.10","uuid":"529697b9-b343-4b1c-ba2f-f5ef692132d4"}]');

批量部署

如果是有线网,那么可以 PXE ,但是最近我接手的比赛要么是无线网,要么就是装机的时候网络还没有搭好,因此 PXE 直接被排除了。 因此之前的方案都是直接量产 U 盘,用 Ubuntu LiveCD 的 preseed 进行无人值守安装。 但之前因为是 iso 形式的镜像,定制化工作都是直接在 iso 中的 squashfs 文件中进行的,这次直接在 img 文件中进行修改,因此这个方案似乎也不太适用了。 同时发了邮件给 John Clevenger ,他给的方案是准备两个 U 盘,一个 U 盘把 img 写入,另一个 U 盘放 Clonezilla ,然后用 Clonezilla 从第一个 U 盘中备份到硬盘。 这个方案看起来是可行的,但是我觉得还是有点麻烦。(如果是 PXE 启动的话,看起来是一个不错的方案)。如果读者有更好的方案,欢迎在评论区留言。

邮件原文
txt
Hi,
  Sorry for the delayed reply; our entire team has been swamped with support 
work since we returned from running the World Finals in Astana.

  You appear to have stumbled across our ICPC 2025 OS "development" page which 
(as you noted) is "not officially released".  As you've deduced, we are working 
to change/improve the process by which people can download, install, and 
customize the ICPC OS image for their contests -- but we're not yet at the 
point of releasing it officially.  We do however now have a draft set of 
instructions for use in several Regional Contests; you can find that at 
https://image.icpc.global/pac2024/ImageBuildInstructions.html.  It describes 
how to create a bootable USB chip, and then how to transfer that bootable image 
onto an internal drive.  You will of course likely want to use the ICPC 2025 
IMG file instead of the Regional Contest IMG file pointed to by that page as 
the starting point, but other than that the instructions ought to work for you.

...

Hope this helps.

John Clevenger
ICPC Technical Director

最终我的泥头车方案是直接把这个 img 中的 rootfs 做成一个 squashfs ,丢进自定义的 archiso 中。在 archiso 中留一个脚本用来分区、解压 squashfs、安装 bootloader 等,而 archiso 可以直接在启动参数中指定自动执行的脚本,这样就可以实现无人值守安装了。

你要问我为什么是 archiso 而不是 debian/ubuntu livecd ,因为我的电脑上装的是 archlinux ,做 archiso 比较方便,而且其他发行版的 livecd 定制化我是真的不会。

airootfs/usr/local/bin/contest-image-install
bash
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#!/bin/bash -e

# Check if running as root
if [ "$EUID" -ne 0 ]; then
    echo "Please run as root"
    exit 1
fi

# Get disk name from command line argument
disk_name=$1
filesystem=${2:-/usr/local/share/icpc/filesystem.squashfs}

# Function to display available disks
show_disks() {
    echo "Available disks:"
    lsblk -d -o NAME,SIZE,MODEL | grep -v loop
}

# Function to confirm disk selection
confirm_disk() {
    local disk=$1
    echo "WARNING: All data on $disk will be erased!"
    read -p "Are you sure you want to continue? (yes/no) " choice
    case "$choice" in 
        yes|YES ) return 0;;
        * ) return 1;;
    esac
}

# If no disk name provided, show available disks and prompt for selection
if [ -z "$disk_name" ]; then
    show_disks
    # Get disk selection
    while true; do
        read -p "Enter the disk name (e.g., sda, nvme0n1): " disk_name
        if [ -b "/dev/$disk_name" ]; then
            if confirm_disk "/dev/$disk_name"; then
                break
            fi
        else
            echo "Invalid disk. Please try again."
        fi
    done
else
    # Validate provided disk name
    if ! [ -b "/dev/$disk_name" ]; then
        echo "Error: Invalid disk /dev/$disk_name"
        exit 1
    fi
fi

echo "Creating partition on /dev/$disk_name..."

# Create partition table and single partition
parted -s /dev/$disk_name mklabel gpt
parted -s /dev/$disk_name mkpart "\"EFI System Partition\"" fat32 1MiB 513MiB
parted -s /dev/$disk_name set 1 esp on
parted -s /dev/$disk_name mkpart "\"root partition\"" ext4 513MiB 100%
parted -s /dev/$disk_name type 2 4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709
parted /dev/$disk_name print

# Wait for partition to be available
sleep 2

# Get the created partition name
if [[ $disk_name == nvme* ]]; then
    partition1="${disk_name}p1"
    partition2="${disk_name}p2"
else
    partition1="${disk_name}1"
    partition2="${disk_name}2"
fi

# Format the partition
echo "Formatting partition..."
yes | mkfs.fat -F32 "/dev/$partition1"
yes | mkfs.ext4 "/dev/$partition2"

# Create mount point and mount partition
mkdir -p /mnt/target
mount "/dev/$partition2" /mnt/target


# Extract squashfs
echo "Extracting system files..."
if [ -f $filesystem ]; then
    unsquashfs -f -d /mnt/target "$filesystem"
else
    echo "Error: filesystem.squashfs not found!"
    exit 1
fi

mkdir -p /mnt/target/boot/efi
mount "/dev/$partition1" /mnt/target/boot/efi


# Generate fstab
echo "Generating fstab..."
cat > /mnt/target/etc/fstab <<EOF
# Static information about the filesystems.
# See fstab(5) for details.

# <file system> <dir> <type> <options> <dump> <pass>
# auto-generated by contest-image-install
EOF
genfstab -U /mnt/target >> /mnt/target/etc/fstab

# Install GRUB
PATH=/usr/sbin:$PATH arch-chroot /mnt/target grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=GRUB
PATH=/usr/sbin:$PATH arch-chroot /mnt/target update-grub

# Unmount everything
echo "Cleaning up..."

umount /mnt/target/boot/efi
umount /mnt/target

echo "Installation complete! The system will now reboot in 10 seconds."
sleep 10
reboot

现场服务器搭建

最近几次比赛都是 Proxmox VE + Ansible 的组合,这次也不例外。

到现场拿到两台服务器直接装上 Proxmox VE,并且导入了之前准备好的服务器镜像,就直接有了 ansible 跑出来的差不多配好的 DOMjudge,另外还有气球服务,打印服务和 Prometheus + Grafana 的监控。

DOMjudge 的安装和配置是我比较熟悉的,因此这部分工作也是我来负责的。当然这次直接用了之前 ansible 跑好的 DOMjudge 8.3.1 , 并 backport 命令行打印的功能和几个我做的 bug fix (#2622 #2906 )。 当然实际上这些 bug fix 都是在 ICPC 香港站做的,这次比赛继续沿用。

DOMjudge 8.3.1 Patch

评测机的话,这次比赛的机器比较充足,所以拿了 4 台选手机直接充当评测机使用。直接跑一遍之前写的 ansible playbook 就完事了。

不过这次因为支持了更多的编程语言,所以评测机的镜像也需要重新制作。说是重新制作,实际上就是直接把选手机的 rootfs 当 DOMjudge chroot。

由于 DOMjudge 8.3 开始可以在界面上显示评测机使用的编译器版本信息。为了收集这些信息,我会交一遍每个支持的编程语言的 Hello World。 遇到的一个坑是 DOMjudge 评测的时候只会使用 chroot 下的 etc usr lib bin (lib64) 目录, 参考这里 。 某些编程语言例如 Kotlin 在选手机镜像中是安装在 /opt 下,并把 kotlinc 等命令软链接到 /usr/local/bin 下的。 评测机在评测的时候就找不到 kotlinc 了。解决方案是在 chroot 下把 Kotlin 装到 /usr/local/lib 下。

打印相关的则是 @panda 老师负责的,我只负责把 DOMjudge 上的打印脚本配对就行了。(当然这个步骤也是 configure-domjudge 脚本在导入配置的时候自动完成的)。

选手机调试

事实证明 20 个队伍和 200 个队伍的工作量没有太大的区别,反正都是 ansible 批量操作的。只有遇到机器完全连不上需要手动操作的时候, 200 个队伍才会有点恐怖(虽然我也没遇到过需要一台台手动修的情况)。

由于只有 20 个队伍,我和 @小羊 给每个队伍都配置了和座位号对应的静态 IP ,并且在核心交换机上做了 MAC 绑定。

然后就是要对摄像头进行调试。这次比赛我们使用的 USB 摄像头,本来准备和之前一样使用 ffmpeg 将摄像头的 mjpeg 输入转码成 h264 后用 vlc 发布。 脚本大概是这样的:

bash
ffmpeg -hide_banner -loglevel error -hwaccel qsv -f alsa -i plughw:CARD=Camera,DEV=0 \
    -f v4l2 -video_size 1280x720 -framerate 30 -c:v mjpeg_qsv -input_format mjpeg -i /dev/video0 \
    -c:v h264_qsv -preset veryfast -g 30 -b:v 5M -profile:v main -c:a aac -f mpegts - | \
    cvlc --play-and-exit - --sout '#transcode{}:standard{access=http,mux=ts,dst=:8080,name=stream,mime=video/ts}'

屏幕采集则是使用 x11grab ,编码成 h264 后用 vlc 发布,脚本大概是这样的:

bash
ffmpeg -hide_banner -hwaccel qsv -f x11grab -framerate 30 -i $DISPLAY \
    -c:v h264_qsv -g 30 -preset veryfast -b:v 5M -f mpegts - | \
    cvlc --play-and-exit - --sout '#transcode{}:standard{access=http,mux=ts,dst=:9090,name=stream,mime=video/ts}'"

不过在这个机器上不知道为什么 ffmpeg 的 qsv 编码器总是会报错,最后只能用 x264 软件编码了。这个需要有机会再研究一下是为什么。 不过好在机器性能够好,实际上软件编码并把脚本挂在 systemd service 上用 CPUAffinity 绑核运行对选手的影响不是很大。

另一个问题是这次由于要接入两个摄像头,直接用 /dev/video0 的设备名在重启或重新插拔的时候顺序可能会乱,并且两个摄像头的型号还是一样的。 @小羊 给了一个方案,使用 /dev/v4l/by-path 下的设备,两个摄像头分别插在前面板和后面板,这样他们 pci 上的路径都是固定的。 于是对着前后面版 pci 路径找了找规律以后写了个脚本(虽然很笨,但是它能用),实在是太牛逼了。

参考脚本
env
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
VIDEO_DEVICE_A1="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:1:1.0-video-index0"
VIDEO_DEVICE_A2="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:3:1.0-video-index0"
VIDEO_DEVICE_A3="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:6:1.0-video-index0"
VIDEO_DEVICE_A4="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:7:1.0-video-index0"
VIDEO_DEVICE_B1="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:4:1.0-video-index0"
VIDEO_DEVICE_B2="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:5:1.0-video-index0"
VIDEO_DEVICE_B3="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:8:1.0-video-index0"
VIDEO_DEVICE_B4="/dev/v4l/by-path/pci-0000:00:14.0-usb-0:10:1.0-video-index0"
AUDIO_DEVICE_A1="usb-0000:00:14.0-1"
AUDIO_DEVICE_A2="usb-0000:00:14.0-3"
AUDIO_DEVICE_A3="usb-0000:00:14.0-6"
AUDIO_DEVICE_A4="usb-0000:00:14.0-7"
AUDIO_DEVICE_B1="usb-0000:00:14.0-4"
AUDIO_DEVICE_B2="usb-0000:00:14.0-5"
AUDIO_DEVICE_B3="usb-0000:00:14.0-8"
AUDIO_DEVICE_B4="usb-0000:00:14.0-10"
VLC_PORT_A="8080"
VLC_PORT_B="8081"
WIDTH="1280"
HEIGHT="720"
FRAMERATE="30"
ENCODER="libx264"

警告

这个脚本最后发现录像并没有达到 30 fps ,只有 10 fps。猜测原因可能是 USB 2.0 接口加上 YUV422 输入导致的,不过现在没有证据。

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#!/bin/bash

set -eo pipefail

DEVICE_SET=${DEVICE_SET:-"UNKNOWN"}
VIDEO_DEVICE_A1=${VIDEO_DEVICE_A1:-"/non/existing/path"}
VIDEO_DEVICE_A2=${VIDEO_DEVICE_A2:-"/non/existing/path"}
VIDEO_DEVICE_A3=${VIDEO_DEVICE_A3:-"/non/existing/path"}
VIDEO_DEVICE_A4=${VIDEO_DEVICE_A4:-"/non/existing/path"}
VIDEO_DEVICE_B1=${VIDEO_DEVICE_B1:-"/non/existing/path"}
VIDEO_DEVICE_B2=${VIDEO_DEVICE_B2:-"/non/existing/path"}
VIDEO_DEVICE_B3=${VIDEO_DEVICE_B3:-"/non/existing/path"}
VIDEO_DEVICE_B4=${VIDEO_DEVICE_B4:-"/non/existing/path"}
AUDIO_DEVICE_A1=${AUDIO_DEVICE_A1:-"non-existing"}
AUDIO_DEVICE_A2=${AUDIO_DEVICE_A2:-"non-existing"}
AUDIO_DEVICE_A3=${AUDIO_DEVICE_A3:-"non-existing"}
AUDIO_DEVICE_A4=${AUDIO_DEVICE_A4:-"non-existing"}
AUDIO_DEVICE_B1=${AUDIO_DEVICE_B1:-"non-existing"}
AUDIO_DEVICE_B2=${AUDIO_DEVICE_B2:-"non-existing"}
AUDIO_DEVICE_B3=${AUDIO_DEVICE_B3:-"non-existing"}
AUDIO_DEVICE_B4=${AUDIO_DEVICE_B4:-"non-existing"}
VLC_PORT_A=${VLC_PORT_A:-"8080"}
VLC_PORT_B=${VLC_PORT_B:-"8080"}
WIDTH=${WIDTH:-"1280"}
HEIGHT=${HEIGHT:-"720"}
FRAMERATE=${FRAMERATE:-"30"}


if [ "$DEVICE_SET" == "A" ]; then
  echo "Using device set A"
  VIDEO_DEVICE_1=${VIDEO_DEVICE_A1}
  VIDEO_DEVICE_2=${VIDEO_DEVICE_A2}
  VIDEO_DEVICE_3=${VIDEO_DEVICE_A3}
  VIDEO_DEVICE_4=${VIDEO_DEVICE_A4}
  AUDIO_DEVICE_1=${AUDIO_DEVICE_A1}
  AUDIO_DEVICE_2=${AUDIO_DEVICE_A2}
  AUDIO_DEVICE_3=${AUDIO_DEVICE_A3}
  AUDIO_DEVICE_4=${AUDIO_DEVICE_A4}
  VLC_PORT=${VLC_PORT_A}
elif [ "$DEVICE_SET" == "B" ]; then
  echo "Using device set B"
  VIDEO_DEVICE_1=${VIDEO_DEVICE_B1}
  VIDEO_DEVICE_2=${VIDEO_DEVICE_B2}
  VIDEO_DEVICE_3=${VIDEO_DEVICE_B3}
  VIDEO_DEVICE_4=${VIDEO_DEVICE_B4}
  AUDIO_DEVICE_1=${AUDIO_DEVICE_B1}
  AUDIO_DEVICE_2=${AUDIO_DEVICE_B2}
  AUDIO_DEVICE_3=${AUDIO_DEVICE_B3}
  AUDIO_DEVICE_4=${AUDIO_DEVICE_B4}
  VLC_PORT=${VLC_PORT_B}
else
  echo "Undefined device set $DEVICE_SET"
  exit 1
fi

echo "Searching USB camera devices..."
if [ -e "$VIDEO_DEVICE_1" ]; then
  VIDEO_DEVICE_LNK=${VIDEO_DEVICE_1}
  AUDIO_DEVICE_HINT=${AUDIO_DEVICE_1}
elif [ -e "$VIDEO_DEVICE_2" ]; then
  VIDEO_DEVICE_LNK=${VIDEO_DEVICE_2}
  AUDIO_DEVICE_HINT=${AUDIO_DEVICE_2}
elif [ -e "$VIDEO_DEVICE_3" ]; then
  VIDEO_DEVICE_LNK=${VIDEO_DEVICE_3}
  AUDIO_DEVICE_HINT=${AUDIO_DEVICE_3}
elif [ -e "$VIDEO_DEVICE_4" ]; then
  VIDEO_DEVICE_LNK=${VIDEO_DEVICE_4}
  AUDIO_DEVICE_HINT=${AUDIO_DEVICE_4}
else
  echo "Video device is not found in VIDEO_DEVICE_$DEVICE_SET[1-4]"
  exit 1
fi
VIDEO_DEVICE="$(readlink $VIDEO_DEVICE_LNK -f)"
echo "Using video device $VIDEO_DEVICE_LNK -> $VIDEO_DEVICE"

if grep "$AUDIO_DEVICE_HINT" /proc/asound/Camera/stream0; then
  AUDIO_DEVICE_NAME="Camera"
elif grep "$AUDIO_DEVICE_HINT" /proc/asound/Camera_1/stream0; then
  AUDIO_DEVICE_NAME="Camera_1"
else
  echo "Cannot find $AUDIO_DEVICE_HINT camera device in ALSA"
fi
AUDIO_DEVICE="plughw:CARD=$AUDIO_DEVICE_NAME,DEV=0"
echo "Using audio device $AUDIO_DEVICE_HINT -> $AUDIO_DEVICE"

exec /bin/bash -c "ffmpeg -hide_banner -loglevel error -f alsa -i $AUDIO_DEVICE \
    -f v4l2 -video_size ${WIDTH}x${HEIGHT} -framerate $FRAMERATE -i $VIDEO_DEVICE \
    -c:v libx264 -preset veryfast -g 30 -b:v 5M -c:a aac -f mpegts - | \
    cvlc --play-and-exit - --sout '#transcode{}:standard{access=http,mux=ts,dst=:$VLC_PORT,name=stream,mime=video/ts}'"

那么问题又来了,一个队伍有两个摄像头的情况下,怎么让 CDS 同时支持两个摄像头流加一个桌面流呢? 通过阅读 ICPC Tools 的代码,队伍的 video 确实可以有多个,于是有以下一个错误示范:

xml
<cds>
  <contest path="/srv/cds/contests/ucup-finals" recordReactions="true">
    <ccs url="https://domjudge/api/v4/contests/ucup-finals" user="cds" password="p@s$w0rd"/>
    <video id="A1"
        desktop="http://172.16.60.11:9090"
        webcam="http://172.16.60.11:8080"
        webcam="http://172.16.60.11:8081"/>
    ...
  </contest>
</cds>

这样连 xml parser 都会报错的。 @小羊 通过阅读文档+源码觉得正确写法是下面这样的:

xml
<cds>
  <contest path="/srv/cds/contests/ucup-finals" recordReactions="true">
    <ccs url="https://domjudge/api/v4/contests/ucup-finals" user="cds" password="p@s$w0rd"/>
    <video id="A1"
        desktop="http://172.16.60.11:9090"
        webcam="http://172.16.60.11:{host}"/>
    ...
  </contest>
  <host host="8080"/>
  <host host="8081"/>
</cds>

虽然他们实际本来是期望的用法是一个队伍有多个电脑,host 作为 IP 地址的模板来使用的。但 host 确实可以不是 IP 地址,这样使用正好也能达到我们的效果。

直播技术支持

直播方面,主要由 @Dup4 负责,他在服务器上用 mediamtx 搭建了一个媒体服务器用来拉场内各种摄像机的流,提供给中英直播团队自由使用。

我主要负责起 live-v3 的 overlay,这个套方案已经在上赛季的大部分区域赛中实装过,所以没有太大问题。需要注意的是, 如果打算使用 v3.3.4 (ucup finals 举办时的最新发行版)。

overlay 中的 featured run 显示会有问题,这个问题被我在 #234 中修复并合并到了主分支, 如果需要使用 featured run 画面,需要使用最新的开发版,或者 cherry-pick 这个 commit 到 v3.3.4 中。(当然也可以降级到 v3.3.3 )。

patch
diff --git a/src/frontend/overlay/src/components/organisms/widgets/Queue.tsx b/src/frontend/overlay/src/components/organisms/widgets/Queue.tsx
index b8da3ab50..565eb08c5 100644
--- a/src/frontend/overlay/src/components/organisms/widgets/Queue.tsx
+++ b/src/frontend/overlay/src/components/organisms/widgets/Queue.tsx
@@ -560,7 +560,7 @@ const QueueComponent = (VARIANT: "vertical" | "horizontal") => ({ shouldShow }:
     const RowsContainerComponent = VARIANT === "horizontal" ? HorizontalRowsContainer : RowsContainer;
     return (
         <>
-            <HorizontalFeatured runInfo={featured}/>
+            {VARIANT === "horizontal" ? <HorizontalFeatured runInfo={featured} /> : <Featured runInfo={featured} />}
             <QueueWrap hasFeatured={!!featured} variant={VARIANT}>
                 <QueueHeader ref={(el) => (el != null) && setHeaderWidth(el.getBoundingClientRect().width)}>
                     <Title>

另外,overlay 中的选手画面必须通过 https 访问,由于以下原因:

Browsers limit the number of HTTP connections with the same domain name. This restriction is defined in the HTTP specification (RFC2616). Most modern browsers allow six connections per domain. Most older browsers allow only two connections per domain.

说人话就是,如果你选手视频流是 http 的话,那么你的浏览器在同一时间只能显示六个画面,其他的画面的连接会被阻塞。 当然这种情况只会在 Split Screen 模式下出现,(4 个选手摄像头画面 + 4 个桌面画面)。

Lidia 说他们在 World Finals 直播的 Split Screen 就遇到了这个问题,他们的解决方案是开两个 overlay 画面,用 query 参数来强制显示某些组件。

当然只要用 h2 的话,这个限制就会被解除。@panda 老师直接用 Caddy 在 hydro/xcpc-tools 前作为 tls 终结。 我只要用 https://tools.ucup.ac/stream/${ip}:8080 这样的 URL 作为摄像头/桌面画面的源就可以了。

这同时也解决了封榜后无法直接访问 CDS 画面的问题,因为 CDS 提供的画面在封榜后是需要认证的,而 overlay 请求的画面是没有加 Authorization 头的。 因此 Authorization 头是在 hydro/xcpc-tools 做 CORS 代理的时候加上的请求头。

后来 Lidia 说他们在 World Finals 直播的时候也用同样的问题,封榜后无法直接访问 CDS 画面,希望我们能在 live-v3 里交个 PR。 因此我把这个功能从 hydro/xcpc-tools 中独立出来,做成了一个单独的工具 cds-auth-proxy ,同时解决跨域和认证问题。


未完待续。

Licensed under CC BY-NC-SA 4.0
最后更新于 2025 年 3 月 18 日 20:59 CST