diff --git a/app/assets/stylesheets/application.bootstrap.scss b/app/assets/stylesheets/application.bootstrap.scss index 3ab899d..1e8f13a 100644 --- a/app/assets/stylesheets/application.bootstrap.scss +++ b/app/assets/stylesheets/application.bootstrap.scss @@ -26,6 +26,7 @@ $bootstrap-icons-font-dir: '/fonts'; @import 'components/stage-badge'; @import 'components/terminal'; @import 'components/logo-wall'; +@import 'components/latency-bars'; @import 'components/browser-frame'; @import 'pages/home'; @import 'pages/hardware'; diff --git a/app/assets/stylesheets/components/_latency-bars.scss b/app/assets/stylesheets/components/_latency-bars.scss new file mode 100644 index 0000000..890fdf6 --- /dev/null +++ b/app/assets/stylesheets/components/_latency-bars.scss @@ -0,0 +1,108 @@ +// The latency comparison on /low-latency. +// +// One measure -- glass-to-glass -- across four receive paths, so this is a +// single hue on a shared scale rather than four categorical colours: the +// reader is comparing magnitude, not telling series apart. Each bar floats +// from the lowest to the highest figure users report for that path, because +// the spread is the honest part; collapsing each path to one number is exactly +// how the 2022 table came to promise something nobody could reach. +// +// The track sits far below 3:1 against the page on purpose. That is allowed +// here because it carries no value of its own -- every bar is labelled in text +// beside it, and the track is hidden from screen readers. +// +// Every cell is placed explicitly rather than left to auto-flow. The markup +// order is label, value, track so that the narrow layout can put the label and +// its number on one line with the bar underneath, without needing a second +// copy of the markup. +.latency-bars { + display: grid; + // The label column is capped rather than sized to its content: Russian + // labels are half again as long as the English ones and were eating the + // track, which is the part carrying the comparison. Past the cap they wrap. + grid-template-columns: minmax(7rem, 13rem) minmax(9rem, 1fr) max-content; + // Dense, because the markup runs label, value, track while the wide layout + // draws them label, track, value. Sparse packing never backfills the hole + // the value leaves behind, so the track dropped to a row of its own and the + // wide layout silently rendered as the narrow one. + grid-auto-flow: dense; + align-items: center; + column-gap: 1rem; + row-gap: .625rem; +} + +.latency-bars__label { + grid-column: 1; + font-size: .9375rem; + line-height: 1.25; +} + +.latency-bars__track { + grid-column: 2; + position: relative; + height: .5rem; + border-radius: .25rem; + background: rgba(var(--bs-primary-rgb), .13); +} + +.latency-bars__range { + position: absolute; + top: 0; + bottom: 0; + min-width: .5rem; + border-radius: .25rem; + background: var(--bs-primary); +} + +.latency-bars__value { + grid-column: 3; + font-weight: 600; + white-space: nowrap; +} + +// Ticks sit under the track only, so they line up with the scale rather than +// with the whole row. Each is placed at its own fraction of the track and +// pulled back by half its width: `justify-content: space-between` would only +// centre the middle tick if all three labels were the same width, and "0" and +// "120" are not, which drags the midpoint visibly off centre. +.latency-bars__axis { + grid-column: 2; + position: relative; + height: 1.1rem; + font-size: .75rem; + color: var(--bs-secondary-color); + + span { position: absolute; } + span:nth-child(1) { left: 0; } + span:nth-child(2) { left: 50%; transform: translateX(-50%); } + span:nth-child(3) { right: 0; } +} + +.latency-bars__unit { + grid-column: 3; + white-space: nowrap; +} + +// Narrow screens: the name and its number share a line, the bar gets the full +// width underneath. Three columns at this width left the bar too short to read +// position off, which is the only thing the bar is for. +@include media-breakpoint-down(md) { + .latency-bars { + grid-template-columns: 1fr max-content; + row-gap: .375rem; + } + + .latency-bars__value { grid-column: 2; } + + .latency-bars__track, + .latency-bars__axis { + grid-column: 1 / -1; + } + + .latency-bars__track { margin-bottom: .75rem; } + + .latency-bars__unit { + grid-column: 1 / -1; + margin-top: -.25rem; + } +} diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index bc18bf5..e332f98 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -55,7 +55,9 @@ def home @meta_description = t('site.default_meta_description') @wall_snapshots = Snapshot.latest_per_camera(limit: 5) @soc_count = Soc.count - @vendor_names = Vendor.order(:name).pluck(:name) + # soc_vendors, not every Vendor: the table also holds sensor makers, and + # counting them as silicon we run on overstates the list. + @vendor_names = Vendor.soc_vendors.order(:name).pluck(:name) render 'pages/home' end diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index 33f7a3c..2b20578 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -72,6 +72,38 @@ module PagesHelper # { name: 'GAINS', url: 'https://gains.company/', img: 'partners/gain_mini.png' } ].freeze + # The latency comparison on /low-latency. + # + # The table this replaces was unchanged 2022 announcement copy, keyed on + # resolution -- which is very nearly free. A 2026 audit of the OpenIPC and + # wfb-ng chat archives found those figures optimistic by 40-160 ms at the + # exact configurations they named, and simultaneously understating the floor + # by half. What actually decides the number is the receive path, so that is + # what these four bars compare. + # + # low and high are the lowest and highest figures users report for each path, + # not an average: collapsing a path to one number is how the old table came to + # promise something nobody could reach. The audit itself carries the + # per-report detail, which is a wiki subject rather than a landing-page one. + # + # Scale is fixed rather than derived from the data so the bars stay comparable + # if a figure changes. + LATENCY_SCALE_MAX = 120 + + LATENCY_PATHS = [ + { key: :ground_station, low: 26, high: 67 }, + { key: :goggles, low: 45, high: 65 }, + { key: :phone, low: 50, high: 100 }, + { key: :desktop, low: 60, high: 100 } + ].freeze + + # Percentage offsets for one bar, against the fixed scale above. + def latency_bar_style(path) + left = path[:low] * 100.0 / LATENCY_SCALE_MAX + width = (path[:high] - path[:low]) * 100.0 / LATENCY_SCALE_MAX + "left: #{left.round(1)}%; width: #{width.round(1)}%" + end + def page_title [@page_title, 'OpenIPC'].join(' - ') end diff --git a/app/models/snapshot.rb b/app/models/snapshot.rb index cf5d849..f4d6e1e 100644 --- a/app/models/snapshot.rb +++ b/app/models/snapshot.rb @@ -22,10 +22,16 @@ class TooSoon < StandardError # limit is interpolated after to_i, not bound, because it lands in a LIMIT # clause where a bind parameter is not accepted; to_i is what makes that safe. def self.latest_per_camera(limit: nil) + # The tie-break on id matters: two rows for one camera can share a + # created_at, and comparing timestamps alone then calls both of them the + # latest. On the homepage, where the result is cut to five, that spent two + # of the five tiles on one camera. sql = 'SELECT s1.* FROM snapshots s1 LEFT JOIN snapshots s2' \ - ' ON (s1.mac_address = s2.mac_address AND s1.created_at < s2.created_at)' \ + ' ON (s1.mac_address = s2.mac_address' \ + ' AND (s1.created_at < s2.created_at' \ + ' OR (s1.created_at = s2.created_at AND s1.id < s2.id)))' \ ' WHERE s2.id IS NULL AND s1.created_at > SUBDATE(NOW(), INTERVAL 1 DAY)' \ - ' ORDER BY created_at DESC' + ' ORDER BY created_at DESC, id DESC' sql += " LIMIT #{limit.to_i}" if limit find_by_sql(sql) end diff --git a/app/views/pages/ecosystem.html.erb b/app/views/pages/ecosystem.html.erb index 646a067..c9dad13 100644 --- a/app/views/pages/ecosystem.html.erb +++ b/app/views/pages/ecosystem.html.erb @@ -30,11 +30,14 @@ { title: t('.section_lowlat_title'), text: t('.section_lowlat_text_html'), projects: [ + # In chain order -- camera, air, ground -- because that is how the three + # read together. devourer is not R&D: it is what PixelPilot receives + # through on Android today, across five Realtek hardware backends. { name: 'waybeam_venc', desc: t('.proj_waybeam'), repo: "#{gh}/waybeam_venc", stage: 'done' }, + { name: 'devourer', desc: t('.proj_devourer'), repo: "#{gh}/devourer", stage: 'done' }, { name: 'PixelPilot_rk', desc: t('.proj_pixelpilot'), repo: "#{gh}/PixelPilot_rk", stage: 'done' }, { name: 'aviateur', desc: t('.proj_aviateur'), repo: "#{gh}/aviateur", stage: 'done' }, - { name: 'telemetry', desc: t('.proj_telemetry'), repo: "#{gh}/telemetry", stage: 'done' }, - { name: 'devourer', desc: t('.proj_devourer'), repo: "#{gh}/devourer", stage: 'rnd' } + { name: 'telemetry', desc: t('.proj_telemetry'), repo: "#{gh}/telemetry", stage: 'done' } ] }, { diff --git a/app/views/pages/get_started.html.erb b/app/views/pages/get_started.html.erb index 9e9a5b9..a4f3eeb 100644 --- a/app/views/pages/get_started.html.erb +++ b/app/views/pages/get_started.html.erb @@ -4,12 +4,28 @@
- <%# Three steps %> + <%# Three steps. + + The terminal is step 1's command, so it sits directly after step 1 in + the markup and a narrow screen reads 1, command, 2, 3. order-lg-last + moves it to the end of the row on a desktop, where the three steps are + side by side and the command wants the full width beneath them. + + It used to live inside the step-1 column, where the ipctool URL is + longer than a third of the article is wide: the visible line ended at + "https://github.com/OpenI" and the rest sat behind a horizontal + scrollbar most systems do not draw. It wraps rather than scrolls now, + so nothing is ever hidden -- but at full width it does not have to wrap + at all, and two wrapped lines read as two commands. %>

1<%= t('.step1_title') %>

<%= t('.step1_text_html') %>

+
+ <%= render 'shared/terminal', id: 'ipctool-cmd', title: 'camera shell', + code: "curl -L -o /tmp/ipctool https://github.com/OpenIPC/ipctool/releases/download/latest/ipctool\nchmod +x /tmp/ipctool && /tmp/ipctool" %> +

2<%= t('.step2_title') %>

<%= t('.step2_text') %>

@@ -21,20 +37,6 @@
- <%# The command for step 1, given the full width of the article rather than - the third of it the step sits in. - - It was inside the step-1 column, where the ipctool URL is longer than - the column is wide: the visible line ended at "https://github.com/OpenI" - and the rest sat behind a horizontal scrollbar most systems do not draw. - The block wraps rather than scrolls now, so nothing is ever hidden -- but - at this width the command does not have to wrap at all on a desktop, and - two lines that are two commands read as two commands. %> -
- <%= render 'shared/terminal', id: 'ipctool-cmd', title: 'camera shell', - code: "curl -L -o /tmp/ipctool https://github.com/OpenIPC/ipctool/releases/download/latest/ipctool\nchmod +x /tmp/ipctool && /tmp/ipctool" %> -
- <%# Honesty box %>
diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index d5f8461..c5a3fa8 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -19,7 +19,13 @@ <%= image_tag snapshot.file.variant(:thumb), alt: t('.wall_snapshot_alt'), loading: (idx.zero? ? 'eager' : 'lazy') %> - <%= snapshot.soc.upcase %> · <%= snapshot.sensor.upcase %> + <%# soc and sensor are whatever the camera chose to send: both are + nullable, and the upload endpoint permits either to be absent. + One such upload used to take the whole homepage down with it. %> + <% caption = [snapshot.soc, snapshot.sensor].reject(&:blank?).map(&:upcase).join(' · ') %> + <% if caption.present? %> + <%= caption %> + <% end %> <% end %> <% (5 - @wall_snapshots.size).times do %> diff --git a/app/views/pages/low_latency.html.erb b/app/views/pages/low_latency.html.erb index df609ee..593e33e 100644 --- a/app/views/pages/low_latency.html.erb +++ b/app/views/pages/low_latency.html.erb @@ -29,7 +29,7 @@
-

TX → wfb-ng → RX

+

TX → devourer → RX

<%# FPV %>
@@ -49,25 +49,41 @@
- <%# Latency, honestly %> -

<%= t('.latency_title') %>

-

<%= t('.latency_intro') %>

-
- - - - - - - - - - - - -
<%= t('.latency_column_config') %><%= t('.latency_column_g2g') %>
<%= t('.latency_720p60') %>~60 ms
<%= t('.latency_1080p60') %>~80 ms
<%= t('.latency_1080p30') %>~100 ms
+ <%# How fast, really. + + One measure across four receive paths, so the bars are one hue on a + shared scale rather than four colours: the reader is comparing + magnitude, not telling series apart. Each bar spans the lowest and + highest figures people report for that path -- see + PagesHelper::LATENCY_PATHS for why it is a range and not a single + number. The track is decoration for the value beside it, so it is + hidden from screen readers. %> +
+
+

<%= t('.latency_title') %>

+

<%= t('.latency_intro') %>

+

<%= t('.latency_note') %>

+
+
+
+ <% PagesHelper::LATENCY_PATHS.each do |path| %> + <%= t(".latency_path_#{path[:key]}") %> + <%# The unit is carried per value as well as once under the axis. A + screen reader reaches the numbers one at a time and would + otherwise hear "26 to 67" with nothing saying what of. %> + <%= "#{path[:low]}–#{path[:high]}" %> <%= t('.latency_axis_unit') %> + + <% end %> + + +
+
-

<%= t('.latency_note') %>

<%# Hardware + credits %>
diff --git a/app/views/snapshots/index.html.erb b/app/views/snapshots/index.html.erb index ff70dc4..fab8e2d 100644 --- a/app/views/snapshots/index.html.erb +++ b/app/views/snapshots/index.html.erb @@ -11,8 +11,14 @@

<%=t('.subtitle') %>

<%# The wall is in the navigation and the footer now, so it gets visitors who have not seen it before and cannot tell from a grid of stills whether - these were collected or volunteered. Two days is the retention - PurgeImagesJob::RETENTION actually enforces. %> + these were collected or volunteered. + + The copy says "a couple of days" rather than naming two, and does not + claim the uploads are verified. PurgeImagesJob::RETENTION is two days, + but it is a cutoff for a sweep that runs once a night, so an image can + outlive it by most of a day; and the upload endpoint authenticates + nobody, so we cannot promise every image came from a camera whose owner + opted in. Both were promised here before, and neither was true. %>

<%= t('.intro_html') %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 3acc4f4..03ab583 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -188,7 +188,7 @@ en: icon: snapshot_alt: 'Image: Snapshot' index: - intro_html: Every image here was uploaded voluntarily by a camera running OpenIPC firmware, and is deleted automatically after two days. Want to join? Enable the Open Wall option in your camera's web interface. + intro_html: Cameras send these themselves — nobody goes out and collects them. Each image is removed a couple of days after it arrives. Want yours here? Turn on the Open Wall option in your camera's web interface. no_signal: No signal snapshot_alt: 'Image: Snapshot' stay_tuned: stay tuned diff --git a/config/locales/pages.en.yml b/config/locales/pages.en.yml index cb58de9..f3f79af 100644 --- a/config/locales/pages.en.yml +++ b/config/locales/pages.en.yml @@ -86,7 +86,7 @@ en: proj_aviateur: 'Cross-platform receiver: watch the link on Windows, Linux, or macOS.' proj_burn: Unbricks HiSilicon devices over serial. proj_coupler: The smooth migration path from vendor firmware to OpenIPC and back. No soldering, no special skills. - proj_devourer: An open foundation for SDR-like receivers built on cheap Wi-Fi hardware. + proj_devourer: 'The link itself: a userspace driver for Realtek Wi-Fi adapters, setting rate, power, and channel packet by packet.' proj_divinus: An open-source streamer for a growing set of platforms. proj_firmware: Universal, Buildroot-based firmware for IP cameras — replaces abandoned vendor systems on dozens of SoC families. proj_ipctool: Identifies the SoC, sensor, and flash chip of nearly any camera — and backs up the stock firmware before you change anything. @@ -174,7 +174,7 @@ en: pillar_firmware_title: Camera firmware pillar_longevity_text: 'We keep vendor SDKs alive on mainline kernels: CVE fixes and modern features long after the vendor walks away.' pillar_longevity_title: Longevity & security - pillar_low_latency_text: An open video link for FPV drones, robots, and teleoperation. Glass-to-glass from about 60 ms on supported hardware. + pillar_low_latency_text: An open video link for FPV drones, robots, and teleoperation. Glass-to-glass from about 30 ms on supported hardware. pillar_low_latency_title: Low-latency video links pillar_tools_text: ONVIF conformance testing, camera emulation, hardware inspection — free tools we built for ourselves and share with everyone. pillar_tools_title: Pro tools @@ -200,7 +200,7 @@ en: wall_snapshot_alt: 'Image: live snapshot from a community camera' low_latency: business_bridge_html: Building a product on this link? Talk to us — we do custom development and OEM work. - credits_text: The radio link exists thanks to wfb-ng, and the ecosystem grows with friends like RubyFPV and Mario FPV. Open source is a team sport. + credits_text: The radio link exists thanks to wfb-ng and devourer, and the ecosystem grows with friends like RubyFPV and Mario FPV. Open source is a team sport. credits_title: Standing on open shoulders cta_chat: Join the FPV chat cta_guide: Read the build guide @@ -208,23 +208,23 @@ en: fpv_title: 'FPV: from lens to goggles' hardware_text_html: Air units are built from supported cameras and AIO boards — start from the supported hardware list. RunCam and EMAX ship OpenIPC-based FPV hardware out of the box. hardware_title: Hardware - hero_lede: 'FPV drones, robots, teleoperation: an open ultra-low-latency video stack from camera to screen. Glass-to-glass from about 60 ms on supported hardware.' + hero_lede: 'FPV drones, robots, teleoperation: an open ultra-low-latency video stack from camera to screen. Glass-to-glass from about 30 ms on supported hardware.' hero_title: Open video links for machines that can't wait. - how_link_text_html: wfb-ng broadcasts video, telemetry, and control over commodity Wi-Fi hardware — no association, no retransmit stalls. + how_link_text_html: devourer carries video, telemetry, and control over commodity Realtek adapters, straight from userspace — no association, no retransmit stalls, and rate, power and channel set per frame. how_link_title: The radio link - how_rx_text_html: PixelPilot turns a Rockchip board into a dedicated ground station, and Aviateur receives on Windows, Linux, and macOS. + how_rx_text_html: PixelPilot turns a Rockchip board into a dedicated ground station — the fastest way to receive. Aviateur runs on the Windows, Linux, or macOS machine you already own. how_rx_title: RX — your side how_title: How the link works - how_tx_text_html: An OpenIPC camera with the Waybeam encoder produces the stream and feeds the radio link. + how_tx_text_html: An OpenIPC camera runs the Waybeam encoder, turning the sensor into a stream and handing it straight to the radio. The frame rate set here decides most of your latency budget. how_tx_title: TX — the air side - latency_1080p30: 1080p30 - latency_1080p60: 1080p60 - latency_720p60: 720p60 - latency_column_config: Configuration - latency_column_g2g: Typical glass-to-glass - latency_intro: 'Numbers depend on SoC, sensor, resolution, exposure, and heat. These are typical figures from our own measurements, not best-case marketing:' - latency_note: Measured with our own open hardware latency meter — built for exactly this job. - latency_title: Latency, honestly + latency_axis_unit: ms glass-to-glass + latency_intro: What decides your number is the ground station, far more than the resolution you pick. These are figures people report from flying this stack on their own hardware. + latency_note: Reported in the OpenIPC and wfb-ng chats, not measured by us. Resolution barely moves the number; frame rate and screen refresh decide it — change nothing but the monitor and the same camera shifts by 120 ms. + latency_path_desktop: Laptop or desktop + latency_path_goggles: Goggles with a built-in VRX + latency_path_ground_station: Dedicated ground station + latency_path_phone: Phone or tablet + latency_title: How fast, really robotics_text1: The same link carries video for industrial robots, AGVs, inspection rigs, and remote operation — anywhere a closed video system would lock you in or a cloud hop would add seconds. robotics_text2: On supported SoCs, frames carry wall-clock timestamps tied to the sensor frame start — millisecond-class synchronization for multi-camera rigs without extra hardware. robotics_title: 'Beyond drones: robots and teleoperation' diff --git a/config/locales/pages.ru.yml b/config/locales/pages.ru.yml index baa3b1d..a9bb32c 100644 --- a/config/locales/pages.ru.yml +++ b/config/locales/pages.ru.yml @@ -86,7 +86,7 @@ ru: proj_aviateur: 'Кроссплатформенный приёмник: смотрите линк на Windows, Linux и macOS.' proj_burn: Восстанавливает «окирпиченные» устройства HiSilicon через последовательный порт. proj_coupler: Плавный переход с вендорской прошивки на OpenIPC и обратно. Без пайки и специальных навыков. - proj_devourer: Открытая основа для SDR-подобных приёмников на дешёвом Wi-Fi-железе. + proj_devourer: 'Сам линк: драйвер Wi-Fi-адаптеров Realtek в userspace, задающий скорость, мощность и канал для каждого пакета.' proj_divinus: Стример с открытым кодом для растущего набора платформ. proj_firmware: Универсальная прошивка на базе Buildroot для IP-камер — заменяет заброшенные вендорские системы на десятках семейств SoC. proj_ipctool: Определяет SoC, сенсор и флеш-чип почти любой камеры — и снимает бэкап заводской прошивки до того, как вы что-то измените. @@ -174,7 +174,7 @@ ru: pillar_firmware_title: Прошивка для камер pillar_longevity_text: 'Мы поддерживаем вендорские SDK на mainline-ядрах: исправления CVE и новые возможности спустя годы после ухода производителя.' pillar_longevity_title: Долголетие и безопасность - pillar_low_latency_text: Открытый видеолинк для FPV-дронов, роботов и телеуправления. От ~60 мс «от стекла до стекла» на поддерживаемом железе. + pillar_low_latency_text: Открытый видеолинк для FPV-дронов, роботов и телеуправления. От ~30 мс «от стекла до стекла» на поддерживаемом железе. pillar_low_latency_title: Видеолинки с низкой задержкой pillar_tools_text: Проверка ONVIF, эмуляция камер, инспекция железа — бесплатные инструменты, которые мы сделали для себя и отдали всем. pillar_tools_title: Инструменты для инженеров @@ -200,7 +200,7 @@ ru: wall_snapshot_alt: 'Изображение: живой снимок с камеры сообщества' low_latency: business_bridge_html: Строите продукт на этом линке? Напишите нам — мы делаем заказную разработку и OEM. - credits_text: Радиолинк существует благодаря wfb-ng, а экосистема растёт вместе с друзьями — RubyFPV и Mario FPV. Open source — командный спорт. + credits_text: Радиолинк существует благодаря wfb-ng и devourer, а экосистема растёт вместе с друзьями — RubyFPV и Mario FPV. Open source — командный спорт. credits_title: Стоим на открытых плечах cta_chat: FPV-чат cta_guide: Гайд по сборке @@ -208,23 +208,23 @@ ru: fpv_title: 'FPV: от объектива до очков' hardware_text_html: Борт собирается из поддерживаемых камер и AIO-плат — начните со списка поддерживаемого оборудования. RunCam и EMAX выпускают FPV-железо на OpenIPC из коробки. hardware_title: Железо - hero_lede: 'FPV-дроны, роботы, телеуправление: открытый видеотракт со сверхнизкой задержкой от камеры до экрана. От ~60 мс «от стекла до стекла» на поддерживаемом железе.' + hero_lede: 'FPV-дроны, роботы, телеуправление: открытый видеотракт со сверхнизкой задержкой от камеры до экрана. От ~30 мс «от стекла до стекла» на поддерживаемом железе.' hero_title: Открытые видеолинки для машин, которые не умеют ждать. - how_link_text_html: wfb-ng вещает видео, телеметрию и управление поверх обычного Wi-Fi-железа — без ассоциации и без пауз на повторную передачу. + how_link_text_html: devourer несёт видео, телеметрию и управление поверх обычных Realtek-адаптеров прямо из userspace — без ассоциации, без пауз на повторную передачу, со скоростью, мощностью и каналом для каждого пакета. how_link_title: Радиолинк - how_rx_text_html: PixelPilot превращает плату на Rockchip в выделенную наземную станцию, а Aviateur принимает на Windows, Linux и macOS. + how_rx_text_html: PixelPilot превращает плату на Rockchip в выделенную наземную станцию — самый быстрый способ принимать. Aviateur работает на том компьютере с Windows, Linux или macOS, который у вас уже есть. how_rx_title: RX — ваша сторона how_title: Как устроен линк - how_tx_text_html: Камера с OpenIPC и энкодером Waybeam формирует поток и отдаёт его в радиолинк. + how_tx_text_html: Камера с OpenIPC и энкодером Waybeam превращает картинку сенсора в поток и отдаёт его прямо в радиолинк. Заданная здесь частота кадров определяет большую часть бюджета задержки. how_tx_title: TX — борт - latency_1080p30: 1080p30 - latency_1080p60: 1080p60 - latency_720p60: 720p60 - latency_column_config: Конфигурация - latency_column_g2g: Типичная задержка «стекло-стекло» - latency_intro: 'Цифры зависят от SoC, сенсора, разрешения, экспозиции и нагрева. Это типичные значения из наших собственных измерений, а не лучший случай из маркетинга:' - latency_note: Измерено нашим собственным открытым аппаратным измерителем задержки, сделанным ровно для этой задачи. - latency_title: Честно про задержку + latency_axis_unit: мс «от стекла до стекла» + latency_intro: Задержку решает наземная станция, а вовсе не выбранное разрешение. Это цифры, которые присылают те, кто реально летает на этом стеке, на своём железе. + latency_note: Из чатов OpenIPC и wfb-ng, а не наши замеры. Разрешение почти не влияет; решают частота кадров и частота обновления экрана — поменяйте только монитор, и та же камера сдвинется на 120 мс. + latency_path_desktop: Ноутбук или десктоп + latency_path_goggles: Очки со встроенным VRX + latency_path_ground_station: Выделенная наземная станция + latency_path_phone: Телефон или планшет + latency_title: Насколько быстро на самом деле robotics_text1: Тот же линк несёт видео для промышленных роботов, AGV, инспекционных систем и удалённого управления — везде, где закрытая видеосистема привязала бы вас к вендору, а путь через облако добавил бы секунды. robotics_text2: На поддерживаемых SoC кадры несут метки времени, привязанные к началу кадра сенсора, — миллисекундная синхронизация многокамерных систем без дополнительного железа. robotics_title: 'Не только дроны: роботы и телеуправление' diff --git a/config/locales/pages.zh.yml b/config/locales/pages.zh.yml index e437668..cfe4fb9 100644 --- a/config/locales/pages.zh.yml +++ b/config/locales/pages.zh.yml @@ -86,7 +86,7 @@ zh: proj_aviateur: '跨平台接收端:在 Windows、Linux 或 macOS 上观看链路画面。' proj_burn: 通过串口解救变砖的海思设备。 proj_coupler: 在原厂固件与 OpenIPC 之间平滑迁移、并可回退的方案。无需焊接,也不需要特殊技能。 - proj_devourer: 基于廉价 Wi-Fi 硬件构建类 SDR 接收端的开放基础。 + proj_devourer: '链路本身:用户态的 Realtek Wi-Fi 网卡驱动,逐包设定速率、功率与信道。' proj_divinus: 面向不断增加的平台的开源推流器。 proj_firmware: 基于 Buildroot 的通用 IP 摄像机固件——在数十个 SoC 系列上取代已被弃用的原厂系统。 proj_ipctool: 识别几乎任何摄像机的 SoC、传感器和闪存芯片——并在你做任何改动之前备份原厂固件。 @@ -174,7 +174,7 @@ zh: pillar_firmware_title: 摄像机固件 pillar_longevity_text: '我们让原厂 SDK 在主线内核上继续存活:在厂商撒手很久之后,依然提供 CVE 修复与现代特性。' pillar_longevity_title: 长期维护与安全 - pillar_low_latency_text: 面向 FPV 无人机、机器人与遥操作的开放视频链路。在受支持的硬件上,端到端延迟低至约 60 毫秒。 + pillar_low_latency_text: 面向 FPV 无人机、机器人与遥操作的开放视频链路。在受支持的硬件上,端到端延迟低至约 30 毫秒。 pillar_low_latency_title: 低延迟视频链路 pillar_tools_text: ONVIF 一致性测试、摄像机模拟、硬件检测——我们为自己打造并与所有人分享的免费工具。 pillar_tools_title: 专业工具 @@ -200,7 +200,7 @@ zh: wall_snapshot_alt: '图片:来自社区摄像机的实时快照' low_latency: business_bridge_html: 想基于这条链路做产品?和我们谈谈——我们承接定制开发与 OEM 合作。 - credits_text: 这条无线链路得益于 wfb-ng,整个生态也在 RubyFPV、Mario FPV 等伙伴的参与下不断成长。开源是一项团队运动。 + credits_text: 这条无线链路得益于 wfb-ng 与 devourer,整个生态也在 RubyFPV、Mario FPV 等伙伴的参与下不断成长。开源是一项团队运动。 credits_title: 站在开放的肩膀上 cta_chat: 加入 FPV 聊天群 cta_guide: 阅读搭建指南 @@ -208,23 +208,23 @@ zh: fpv_title: 'FPV:从镜头到眼镜' hardware_text_html: 天空端由受支持的摄像机和 AIO 板搭建——请从支持硬件列表开始。RunCam 与 EMAX 出厂即提供基于 OpenIPC 的 FPV 硬件。 hardware_title: 硬件 - hero_lede: 'FPV 无人机、机器人、遥操作:一套从摄像机到屏幕的开放超低延迟视频方案。在受支持的硬件上,端到端延迟低至约 60 毫秒。' + hero_lede: 'FPV 无人机、机器人、遥操作:一套从摄像机到屏幕的开放超低延迟视频方案。在受支持的硬件上,端到端延迟低至约 30 毫秒。' hero_title: 为等不起的机器提供开放视频链路。 - how_link_text_html: wfb-ng 在普通 Wi-Fi 硬件上广播视频、遥测与控制信号——无需关联,也不会因重传而卡顿。 + how_link_text_html: devourer 直接在用户态承载视频、遥测与控制信号,运行于常见的 Realtek 网卡——无需关联,不会因重传而卡顿,速率、功率与信道逐包设定。 how_link_title: 无线链路 - how_rx_text_html: PixelPilot 把一块 Rockchip 板子变成专用地面站,Aviateur 则可在 Windows、Linux 和 macOS 上接收。 + how_rx_text_html: PixelPilot 把一块 Rockchip 板子变成专用地面站——这是最快的接收方式。Aviateur 则直接跑在你已有的 Windows、Linux 或 macOS 电脑上。 how_rx_title: 接收端——你这一侧 how_title: 这条链路是怎么工作的 - how_tx_text_html: 一台装有 Waybeam 编码器的 OpenIPC 摄像机负责产生码流并送入无线链路。 + how_tx_text_html: 装有 Waybeam 编码器的 OpenIPC 摄像机把传感器画面变成码流,直接送入无线链路。在这里设定的帧率决定了延迟预算的大部分。 how_tx_title: 发送端——天空这一侧 - latency_1080p30: 1080p30 - latency_1080p60: 1080p60 - latency_720p60: 720p60 - latency_column_config: 配置 - latency_column_g2g: 典型端到端延迟 - latency_intro: '具体数值取决于 SoC、传感器、分辨率、曝光和温度。以下是我们自己实测得到的典型值,而不是最理想情况下的宣传数字:' - latency_note: 使用我们自己的开源硬件延迟测量仪测得——它正是为这件事而造的。 - latency_title: 延迟,实话实说 + latency_axis_unit: ms 端到端延迟 + latency_intro: 真正决定这个数字的是地面站,而不是你选的分辨率。以下是真正在用这套方案飞行的人,在自己硬件上报告的结果。 + latency_note: 来自 OpenIPC 与 wfb-ng 社区聊天,而非我们自己的实测。分辨率几乎不影响这个数字,真正决定它的是帧率和屏幕刷新率——只换一台显示器,同一台摄像机就会相差 120 毫秒。 + latency_path_desktop: 笔记本或台式机 + latency_path_goggles: 内置 VRX 的眼镜 + latency_path_ground_station: 专用地面站 + latency_path_phone: 手机或平板 + latency_title: 到底有多快 robotics_text1: 同一条链路也为工业机器人、AGV、巡检设备和远程操作传输视频——凡是封闭视频系统会把你锁死、或者绕行云端会带来数秒延迟的场合,都用得上。 robotics_text2: 在受支持的 SoC 上,画面帧会携带与传感器曝光起始对齐的挂钟时间戳——无需额外硬件即可实现多摄像机毫秒级同步。 robotics_title: '不止于无人机:机器人与遥操作' diff --git a/config/locales/ru.yml b/config/locales/ru.yml index a779291..a9aa161 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -190,7 +190,7 @@ ru: icon: snapshot_alt: 'Изображение: снимок' index: - intro_html: Каждый снимок здесь добровольно загружен камерой с прошивкой OpenIPC и удаляется автоматически через два дня. Хотите присоединиться? Включите опцию Open Wall в веб-интерфейсе своей камеры. + intro_html: Камеры присылают снимки сами — никто их не собирает. Каждый снимок удаляется через пару дней после загрузки. Хотите, чтобы здесь был ваш? Включите опцию Open Wall в веб-интерфейсе камеры. no_signal: Нет сигнала snapshot_alt: 'Изображение: снимок' stay_tuned: Следите за обновлениями diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 3fc6657..2a1294b 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -188,7 +188,7 @@ zh: icon: snapshot_alt: 图片:快照 index: - intro_html: 这里的每一张图片都是由运行 OpenIPC 固件的摄像机自愿上传的,并会在两天后自动删除。想加入吗?在你的摄像机 Web 界面中开启 Open Wall 选项即可。 + intro_html: 这些图片是摄像机自己上传的——没有人去采集。每张图片会在上传几天后被删除。想让你的画面出现在这里?在摄像机的 Web 界面中开启 Open Wall 选项即可。 no_signal: 没信号 snapshot_alt: 图片:快照 stay_tuned: 敬请关注 diff --git a/config/routes.rb b/config/routes.rb index d444bbb..653d89d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,17 +18,28 @@ # # /home was the temporary URL the homepage answered on while it was being # built and nothing linked to it; it is the root now. - get '/home', to: redirect('/') - get '/introduction', to: redirect('/') - get '/aaa', to: redirect('/') - get '/fpv', to: redirect('/low-latency') - get '/our-projects', to: redirect('/ecosystem') - get '/our-software', to: redirect('/ecosystem') - get '/our-channels', to: redirect('/community') - get '/support-open-source', to: redirect('/donate') + # + # redirect('/path') drops the query string, and locale lives in it: a link to + # /introduction?locale=ru landed on the homepage in whatever language the + # browser asked for. keep_query preserves it, so a localized legacy link + # stays in its language across the move. + keep_query = lambda do |to| + redirect { |_params, request| request.query_string.present? ? "#{to}?#{request.query_string}" : to } + end + + get '/home', to: keep_query.call('/') + get '/introduction', to: keep_query.call('/') + get '/aaa', to: keep_query.call('/') + get '/fpv', to: keep_query.call('/low-latency') + get '/our-projects', to: keep_query.call('/ecosystem') + get '/our-software', to: keep_query.call('/ecosystem') + get '/our-channels', to: keep_query.call('/community') + get '/support-open-source', to: keep_query.call('/donate') # 302, not 301: /about is meant to become a page of its own, and a 301 is # cached by browsers indefinitely -- it would outlive the decision. - get '/about', to: redirect('/community', status: 302) + get '/about', to: redirect(status: 302) { |_params, request| + request.query_string.present? ? "/community?#{request.query_string}" : '/community' + } get '/majestic-endpoints', to: 'pages#majestic_endpoints' get '/coupler', to: redirect('https://github.com/openipc//coupler/') diff --git a/test/controllers/redirects_test.rb b/test/controllers/redirects_test.rb index e62f9df..4d35159 100644 --- a/test/controllers/redirects_test.rb +++ b/test/controllers/redirects_test.rb @@ -67,7 +67,10 @@ class RedirectsTest < ActionDispatch::IntegrationTest post '/snapshots' assert_not response.redirect?, 'the camera upload API was shadowed by a redirect' - assert_includes [400, 415, 422, 500], response.status, + # Not 500: SnapshotsController#create answers an empty upload with 415, and + # accepting a server error here would let a controller regression pass as + # though the API still worked. + assert_includes [400, 415, 422], response.status, "expected a request error for an empty upload, got #{response.status}" end diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index 5eeda64..032203e 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -154,4 +154,139 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest assert_path_exists Rails.root.join('app/assets/images', logo[:img]) end end + + # The radio-link card names devourer, OpenIPC's own userspace Realtek driver, + # rather than wfb-ng. wfb-ng keeps its place in the credits -- the link exists + # because of both -- so the test checks the card, not the whole page. + test 'the radio link card names devourer and links it' do + get '/low-latency' + + card = css_select('.card').find { |c| c.text.include?('devourer') } + assert_not_nil card, 'no card on the page names devourer' + assert_not_empty card.css('a[href="https://github.com/OpenIPC/devourer"]'), + 'devourer is named but not linked' + assert_includes response.body, I18n.t('pages.low_latency.credits_text'), + 'the credits no longer thank both projects' + end + + # The latency table on /low-latency was unchanged 2022 announcement copy. It + # was keyed on resolution -- which is very nearly free -- and a 2026 audit of + # the OpenIPC and wfb-ng chat archives found it optimistic by 40-160 ms at the + # exact configurations it named, while understating the floor by half. It now + # compares receive paths, which is what actually decides the number. + test 'the low-latency page compares receive paths, not resolutions' do + get '/low-latency' + + assert_response :success + PagesHelper::LATENCY_PATHS.each do |path| + assert_includes response.body, I18n.t("pages.low_latency.latency_path_#{path[:key]}"), + "the #{path[:key]} path is missing" + assert_includes response.body, "#{path[:low]}–#{path[:high]}", + "the #{path[:key]} figure is missing" + end + ['~60 ms', '~80 ms', '~100 ms'].each do |stale| + assert_not_includes response.body, stale, "the 2022 figure #{stale} is back on the page" + end + assert_includes response.body, 'about 30 ms', 'the hero no longer states the real floor' + end + + # soc and sensor are nullable and the upload endpoint permits both to be + # absent, so one such upload used to take the whole homepage down with it. + test 'the homepage survives a snapshot that named neither its soc nor its sensor' do + snapshot = Snapshot.new(mac_address: '02:00:00:00:00:99', ip_address: '198.51.100.7', + soc: nil, sensor: nil) + # The blob validator wants a real image type and at least 10 kilobytes. + snapshot.file.attach(io: StringIO.new("\xFF\xD8\xFF#{'x' * 12_000}"), + filename: 'wall.jpg', content_type: 'image/jpeg') + snapshot.save! + + get '/' + + assert_response :success + end + + # The strip says "runs on silicon by". The vendors table also holds sensor + # makers, and listing those claims silicon we do not run on. + test 'the silicon strip lists chip vendors, not sensor makers' do + chipmaker = Vendor.create!(name: 'Teststar Semiconductor') + Soc.create!(model: 'TS1234', vendor: chipmaker) + sensor_maker = Vendor.create!(name: 'Testsen Imaging') + + get '/' + + assert_includes response.body, chipmaker.name + assert_not_includes response.body, sensor_maker.name, + 'a vendor with no SoCs is being counted as silicon we run on' + end + + # Locale rides in the query string, and redirect('/path') drops it, so a + # localized legacy link used to land in the browser's language instead. + test 'legacy redirects keep the locale they were asked for' do + { '/introduction' => '/', '/fpv' => '/low-latency', + '/our-projects' => '/ecosystem', '/about' => '/community' }.each do |from, to| + get "#{from}?locale=ru" + + assert_redirected_to "#{to}?locale=ru" + end + end + + # The command belongs to step 1. Below the lg breakpoint the steps stack, and + # source order is what the reader gets. + test 'the ipctool command comes before step two' do + get '/get-started' + + body = response.body + assert_operator body.index('ipctool-cmd'), :<, body.index(ERB::Util.html_escape(I18n.t('pages.get_started.step2_title'))), + 'the command for step 1 renders after step 2' + end + + # Sighted readers get the unit once, under the axis. A screen reader reaches + # the numbers one at a time, so each has to carry it -- and the axis and its + # unit, being decoration for those numbers, must not be read out twice. + test 'every latency figure says what unit it is in' do + get '/low-latency' + + unit = I18n.t('pages.low_latency.latency_axis_unit') + values = css_select('.latency-bars__value') + + assert_equal PagesHelper::LATENCY_PATHS.size, values.size + values.each do |value| + assert_includes value.text, unit, "#{value.text.strip} does not say what unit it is in" + end + assert_equal 'true', css_select('.latency-bars__unit').first['aria-hidden'] + assert_equal 'true', css_select('.latency-bars__axis').first['aria-hidden'] + css_select('.latency-bars__track').each do |track| + assert_equal 'true', track['aria-hidden'], 'a bar is read out as if it were content' + end + end + + # The bars are positioned by inline percentages against a fixed scale. A + # figure edited past that scale would render a bar running off the end of its + # track, which no test of the copy would notice. + test 'every latency bar fits the scale it is drawn against' do + PagesHelper::LATENCY_PATHS.each do |path| + assert_operator path[:low], :<, path[:high], "#{path[:key]} is not a range" + assert_operator path[:high], :<=, PagesHelper::LATENCY_SCALE_MAX, + "#{path[:key]} runs past the end of the scale" + end + end + + # Every figure on that page is a user report from a private Telegram group. + # The `t.me/c/...` links resolve only for members of those groups, so they + # would 404 for a visitor, and the reporters have not been asked whether they + # want their names on the marketing site. + test 'the low-latency page cites no private Telegram links and no unpublished meter' do + %w[en ru zh].each do |locale| + get "/low-latency?locale=#{locale}" + + assert_response :success + assert_not_includes response.body, 't.me/c/', "a private Telegram link leaked into #{locale}" + assert_includes response.body, ERB::Util.html_escape(I18n.t('pages.low_latency.latency_title', locale: locale)), + "the latency section is missing in #{locale}" + end + + get '/low-latency?locale=en' + assert_not_includes response.body, 'latency meter', + 'the page claims a meter whose design and runs are not published' + end end