* fix(ui): timeline box colors pre-blended in JS — visible on any WebView2, color-mix dependency removed (#963) #951 moved the segment-box palette to `color-mix(in srgb, tint 45%, var(--chrome-bg))` strings applied as inline styles. WebView2/Chromium < 111 has no color-mix, so the CSSOM rejects the whole `background` assignment — and since .seg-track__box declares no background of its own, the boxes rendered fully transparent on pinned/enterprise WebView2 runtimes (the Windows installer never enforces a minimum runtime). Fix the class, not the instance: no engine-dependent CSS may reach this lane's inline styles. The 0.45·tint + 0.55·bg blend now happens in JS — timeline.js keeps the tints as numeric [r,g,b], reads --chrome-bg off the document root (fallback #0f1011), and emits literal `rgb(r, g, b)` strings every engine parses. Pixel-identical to what color-mix painted. Theme-awareness is preserved by re-blending when [data-theme] changes on <html> (the seam App.jsx switches themes through), observed via MutationObserver; SegmentTrack subscribes with useSyncExternalStore so mounted boxes recolor live. Guards updated: palette entries must match plain opaque rgb() (no color-mix/var()/alpha), the default-theme blend is asserted against independently computed literals, theme-change re-blend and rgb()/ garbage --chrome-bg parsing are covered, and SegmentTrack's rendered inline background is asserted to be a literal rgb() — fails on any reintroduction of engine-dependent CSS in this lane. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changelog): add WebView2 box-color fix under [Unreleased] (#968) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
mergetest
parent
d959aae41b
commit
738d45f1c7
@@ -15,6 +15,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Timeline segment boxes are visible on every WebView2 runtime.** The v0.3.10 flicker fix switched box colors to a newer CSS feature (`color-mix`) applied as an inline style — on WebView2 runtimes older than ~March 2023 (pinned enterprise/offline installs) that renders as *fully transparent*, turning "flickering boxes" into "no boxes at all" while looking perfect on up-to-date machines. Colors are now pre-blended in plain JavaScript to universally-supported `rgb()` values — pixel-identical on modern runtimes, theme-aware, and guarded by a test that fails if an engine-dependent color ever reaches the timeline again. (#968)
|
||||
- **Dubbed dialogue stops starting seconds early because of footsteps.** Dialogue starts are snapped to the first detected sound — and a single 20 ms burst (footsteps, a door, a sigh) counted as "speech", with no limit on how far a start could jump, and the snap even ran on the raw mix when vocal separation had failed. Onsets now require sustained speech-like energy, long jumps are only allowed across genuinely silent spans (so the original fix for whisper's stretched starts keeps working), and snapping turns off entirely when vocals weren't separated. Credit to the community reporter whose "footsteps theory" was exactly right. (#967)
|
||||
- **Completed dub tracks always show their video tabs.** Opening a project with a finished dubbed track hid the Original/track switcher until you re-selected the language — visibility was keyed to the language dropdown instead of the project's tracks, and restored projects couldn't set the language because the history database froze it at empty forever. Tabs now render from the tracks themselves, history keeps its language (existing projects heal without migration), restoring a project can no longer 404 the video preview, and track pills gained duration/timing tooltips plus an accurate now-playing indicator. (#956)
|
||||
- **Running from source works again, and the install docs stop lying.** `bun run desktop-prod` broke when the frontend became a workspace (`bunx` could fetch the wrong "tauri" package from npm — fixed everywhere including CI); the Linux white-screen guidance now leads with the variable that actually fixes modern Ubuntu (`WEBKIT_DISABLE_DMABUF_RENDERER=1`, with the exact `EGL_BAD_PARAMETER` error quoted); Windows docs now state plainly that GPU acceleration is NVIDIA-only there; the Linux docs document the ROCm support that already shipped (the "planned follow-up" note was stale); and prerequisites are split installer-vs-source with git and curl included. (#964)
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Headphones } from 'lucide-react';
|
||||
import {
|
||||
REGION_COLORS,
|
||||
getRegionColors,
|
||||
subscribeRegionColors,
|
||||
SNAP_PX,
|
||||
visibleSegmentRange,
|
||||
snapTime,
|
||||
@@ -135,13 +144,14 @@ export default function SegmentTrack({
|
||||
[effSegments, viewStart, viewEnd],
|
||||
);
|
||||
|
||||
// Palette snapshot re-blends against the new --chrome-bg on theme change
|
||||
// (#963) — new array identity per re-blend, so the memo below recolors.
|
||||
const regionColors = useSyncExternalStore(subscribeRegionColors, getRegionColors);
|
||||
const speakerColor = useMemo(() => {
|
||||
const speakers = [...new Set(segments.map((s) => s.speaker_id).filter(Boolean))];
|
||||
const bySpeaker = new Map(
|
||||
speakers.map((sp, i) => [sp, REGION_COLORS[i % REGION_COLORS.length]]),
|
||||
);
|
||||
return (seg, idx) => bySpeaker.get(seg.speaker_id) || REGION_COLORS[idx % REGION_COLORS.length];
|
||||
}, [segments]);
|
||||
const bySpeaker = new Map(speakers.map((sp, i) => [sp, regionColors[i % regionColors.length]]));
|
||||
return (seg, idx) => bySpeaker.get(seg.speaker_id) || regionColors[idx % regionColors.length];
|
||||
}, [segments, regionColors]);
|
||||
|
||||
// ── Onset tick strip (one viewport-sized canvas, non-interactive) ───────
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import SegmentTrack from './SegmentTrack';
|
||||
|
||||
// Mocked transport: fixed pxPerSec/scrollLeft, no WaveSurfer. jsdom has no
|
||||
@@ -211,6 +211,46 @@ describe('SegmentTrack — compositor-safe positioning (#373)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SegmentTrack — engine-independent box paint (#963)', () => {
|
||||
const root = document.documentElement;
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore the default theme and let the palette observer settle so the
|
||||
// module-level cache can't leak into other tests in this file.
|
||||
await act(async () => {
|
||||
root.style.removeProperty('--chrome-bg');
|
||||
root.removeAttribute('data-theme');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
|
||||
it('inline background is a literal opaque rgb() — no color-mix/var() the CSSOM could reject', () => {
|
||||
// WebView2/Chromium < 111 rejects a color-mix() inline-style assignment
|
||||
// wholesale, and .seg-track__box declares no fallback background → the
|
||||
// boxes rendered fully transparent (#963). The inline value must be
|
||||
// plain rgb() so every engine parses it.
|
||||
setup();
|
||||
for (const el of screen.getAllByRole('option')) {
|
||||
expect(el.style.background).toMatch(/^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
|
||||
}
|
||||
// Default theme, first palette slot: 0.45·rgb(211,134,155) over #0f1011.
|
||||
expect(box(0).style.background).toBe('rgb(103, 69, 79)');
|
||||
});
|
||||
|
||||
it('boxes re-blend live when the theme changes ([data-theme] on <html>)', async () => {
|
||||
setup();
|
||||
expect(box(0).style.background).toBe('rgb(103, 69, 79)');
|
||||
await act(async () => {
|
||||
// Same seam App.jsx uses: swap --chrome-bg and flag the theme.
|
||||
root.style.setProperty('--chrome-bg', '#1e293b');
|
||||
root.setAttribute('data-theme', 'slate');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // flush MutationObserver
|
||||
});
|
||||
// round(0.45·[211,134,155] + 0.55·[30,41,59])
|
||||
expect(box(0).style.background).toBe('rgb(111, 83, 102)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SegmentTrack — pointer + selection', () => {
|
||||
it('pointerdown selects the segment (table sync)', () => {
|
||||
const { onSelectSeg } = setup();
|
||||
|
||||
+103
-17
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* timeline.js — pure math/state helpers for the dub timeline segment editor
|
||||
* (#280, item 3). Everything here is DOM-free and unit-tested; SegmentTrack
|
||||
* only does rendering + pointer/keyboard plumbing on top of these.
|
||||
* (#280, item 3). Everything here is DOM-free and unit-tested — except the
|
||||
* region palette below, which reads `--chrome-bg` off the document root (with
|
||||
* a non-DOM fallback) so the box colors can be pre-blended in JS (#963).
|
||||
* SegmentTrack only does rendering + pointer/keyboard plumbing on top.
|
||||
*
|
||||
* All times are seconds (float), all pixels are CSS px.
|
||||
*/
|
||||
@@ -23,24 +25,108 @@ const GRID_SNAP_MAX_PX_PER_SEC = 40;
|
||||
// FULLY OPAQUE by design (#373): these used to be `rgba(…, 0.45)` and relied
|
||||
// on alpha compositing over the panel behind the track — and on some Windows
|
||||
// GPU/WebView2 drivers, semi-transparent paints on the (formerly
|
||||
// transform-animated) lane flashed invisible during playback. Each entry now
|
||||
// transform-animated) lane flashed invisible during playback. Each entry
|
||||
// pre-blends the same 45% tint against the surface behind the lane
|
||||
// (`--chrome-bg`, the .studio-panel background) via color-mix, which computes
|
||||
// the identical pixels (0.45·tint + 0.55·bg) with zero alpha — and stays
|
||||
// theme-aware because the variable resolves per [data-theme]. Do NOT
|
||||
// reintroduce alpha here; guarded by timeline.test.js.
|
||||
const opaqueTint = (r, g, b) =>
|
||||
`color-mix(in srgb, rgb(${r} ${g} ${b}) 45%, var(--chrome-bg, #0f1011))`;
|
||||
export const REGION_COLORS = [
|
||||
opaqueTint(211, 134, 155),
|
||||
opaqueTint(131, 165, 152),
|
||||
opaqueTint(184, 187, 38),
|
||||
opaqueTint(250, 189, 47),
|
||||
opaqueTint(142, 192, 124),
|
||||
opaqueTint(254, 128, 25),
|
||||
opaqueTint(104, 157, 106),
|
||||
// (`--chrome-bg`, the .studio-panel background), computing the identical
|
||||
// pixels (0.45·tint + 0.55·bg) with zero alpha.
|
||||
//
|
||||
// PRE-BLENDED IN JS by design (#963): #951 did the blend with
|
||||
// `color-mix(in srgb, …)` inside the inline style — but WebView2/Chromium
|
||||
// < 111 has no color-mix, the CSSOM rejects the whole `background`
|
||||
// assignment, and .seg-track__box declares no background of its own, so the
|
||||
// boxes rendered fully transparent on pinned/enterprise WebView2 runtimes.
|
||||
// The blend now happens here in JS and the inline style receives a literal
|
||||
// `rgb(r, g, b)` every engine can parse. Theme-awareness is preserved by
|
||||
// re-reading `--chrome-bg` when [data-theme] changes on the document root
|
||||
// (the seam App.jsx uses to switch themes). Do NOT reintroduce alpha OR any
|
||||
// engine-dependent CSS function here; guarded by timeline.test.js +
|
||||
// SegmentTrack.test.jsx.
|
||||
const REGION_TINTS = [
|
||||
[211, 134, 155],
|
||||
[131, 165, 152],
|
||||
[184, 187, 38],
|
||||
[250, 189, 47],
|
||||
[142, 192, 124],
|
||||
[254, 128, 25],
|
||||
[104, 157, 106],
|
||||
];
|
||||
|
||||
// Gruvbox Dark `--chrome-bg` (#0f1011) — the :root default in index.css.
|
||||
// Used when the variable is unreadable (non-DOM test runner, CSS not loaded).
|
||||
const FALLBACK_CHROME_BG = [15, 16, 17];
|
||||
|
||||
/** Parse a CSS color literal (#rgb, #rrggbb, rgb()/rgba()) → [r,g,b] | null. */
|
||||
function parseCssColor(raw) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
let m = /^#([0-9a-f]{3})$/i.exec(s);
|
||||
if (m) return [...m[1]].map((c) => parseInt(c + c, 16));
|
||||
m = /^#([0-9a-f]{6})$/i.exec(s);
|
||||
if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
|
||||
m = /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})\s*(?:[,/][^)]*)?\)$/i.exec(s);
|
||||
if (m) return [+m[1], +m[2], +m[3]];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Blend a 45% tint over an opaque background — same math as
|
||||
* `color-mix(in srgb, tint 45%, bg)`, emitted as a literal rgb() string. */
|
||||
export function blendRegionColor(tint, bg) {
|
||||
const [r, g, b] = tint.map((c, i) => Math.round(0.45 * c + 0.55 * bg[i]));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function readChromeBg() {
|
||||
try {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--chrome-bg');
|
||||
return parseCssColor(raw) ?? FALLBACK_CHROME_BG;
|
||||
} catch {
|
||||
return FALLBACK_CHROME_BG; // SSR / non-DOM test runner
|
||||
}
|
||||
}
|
||||
|
||||
function blendPalette() {
|
||||
const bg = readChromeBg();
|
||||
return REGION_TINTS.map((tint) => blendRegionColor(tint, bg));
|
||||
}
|
||||
|
||||
/**
|
||||
* REGION_COLORS — the current palette as literal `rgb(r, g, b)` strings.
|
||||
* Live ESM binding: re-assigned (never mutated in place) when the theme
|
||||
* changes, so `getRegionColors()` is a stable-reference snapshot fit for
|
||||
* useSyncExternalStore, while plain `REGION_COLORS[i]` reads stay correct.
|
||||
*/
|
||||
export let REGION_COLORS = blendPalette();
|
||||
|
||||
const regionColorListeners = new Set();
|
||||
|
||||
/** Snapshot accessor for useSyncExternalStore — new array identity per re-blend. */
|
||||
export function getRegionColors() {
|
||||
return REGION_COLORS;
|
||||
}
|
||||
|
||||
/** Subscribe to palette re-blends (theme changes). Returns unsubscribe. */
|
||||
export function subscribeRegionColors(cb) {
|
||||
regionColorListeners.add(cb);
|
||||
return () => regionColorListeners.delete(cb);
|
||||
}
|
||||
|
||||
function refreshRegionColors() {
|
||||
const next = blendPalette();
|
||||
if (next.every((c, i) => c === REGION_COLORS[i])) return;
|
||||
REGION_COLORS = next;
|
||||
for (const cb of regionColorListeners) cb();
|
||||
}
|
||||
|
||||
// Theme seam: App.jsx switches themes by setting/removing [data-theme] on
|
||||
// <html> (index.css scopes every theme's --chrome-bg to that attribute), so
|
||||
// observing it is exactly "re-read on theme change".
|
||||
if (typeof document !== 'undefined' && typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(refreshRegionColors).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* visibleSegmentRange — windowing for the virtualized track.
|
||||
*
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
MIN_SEG_DUR,
|
||||
MAX_OVERLAP,
|
||||
REGION_COLORS,
|
||||
blendRegionColor,
|
||||
getRegionColors,
|
||||
subscribeRegionColors,
|
||||
visibleSegmentRange,
|
||||
snapTime,
|
||||
snapCandidates,
|
||||
@@ -227,28 +230,91 @@ describe('nearestOnset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('REGION_COLORS — opaque paint guard (#373)', () => {
|
||||
// Semi-transparent box fills flash on some Windows GPU/WebView2 drivers
|
||||
// when the lane gets composited. Every palette entry must be fully opaque:
|
||||
// no alpha-carrying color syntax anywhere in the value.
|
||||
it('no entry carries an alpha channel', () => {
|
||||
describe('REGION_COLORS — opaque JS-pre-blended paint guard (#373, #963)', () => {
|
||||
// Two invariants, one per historical regression:
|
||||
// #373 — semi-transparent box fills flash on some Windows GPU/WebView2
|
||||
// drivers when the lane gets composited → every entry must be
|
||||
// fully opaque (no alpha channel anywhere).
|
||||
// #963 — engine-dependent CSS (color-mix, var()) in an inline style is
|
||||
// REJECTED wholesale by the CSSOM on WebView2/Chromium < 111, and
|
||||
// .seg-track__box has no background of its own → boxes invisible.
|
||||
// Every entry must therefore be a literal rgb() any engine parses,
|
||||
// with the 45%-tint-over---chrome-bg blend done in JS.
|
||||
const root = document.documentElement;
|
||||
const flushThemeObserver = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
afterEach(async () => {
|
||||
root.style.removeProperty('--chrome-bg');
|
||||
root.removeAttribute('data-theme');
|
||||
await flushThemeObserver(); // let the palette settle back to the default
|
||||
});
|
||||
|
||||
it('every entry is a literal fully-opaque rgb() — no engine-dependent CSS, no alpha', () => {
|
||||
expect(REGION_COLORS.length).toBeGreaterThan(0);
|
||||
for (const color of REGION_COLORS) {
|
||||
expect(color).not.toMatch(/rgba\(|hsla\(|transparent/i); // legacy alpha fns
|
||||
expect(color).not.toMatch(/\/\s*(?:0?\.\d+|\d+%)/); // modern `… / alpha` syntax
|
||||
for (const hex of color.match(/#[0-9a-fA-F]+/g) ?? []) {
|
||||
expect([4, 7]).toContain(hex.length); // #rgb / #rrggbb only — no alpha digits
|
||||
}
|
||||
expect(color).toMatch(/^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/);
|
||||
// The class of the #963 bug: anything the target engines' CSSOM may
|
||||
// reject as an inline-style value.
|
||||
expect(color).not.toMatch(/color-mix|var\(|calc\(/i);
|
||||
expect(color).not.toMatch(/rgba\(|hsla\(|transparent|\/|%/i); // #373: no alpha syntax
|
||||
}
|
||||
});
|
||||
|
||||
it('color-mix mixes only opaque inputs and preserves the original 45% tint ratio', () => {
|
||||
for (const color of REGION_COLORS) {
|
||||
const m = color.match(
|
||||
/^color-mix\(in srgb, rgb\(\d+ \d+ \d+\) (\d+)%, var\(--chrome-bg, (#[0-9a-fA-F]{6})\)\)$/,
|
||||
);
|
||||
expect(m, `unexpected palette entry shape: ${color}`).not.toBeNull();
|
||||
expect(m[1]).toBe('45'); // same visual weight the 0.45-alpha fills had
|
||||
it('default theme: blends exactly 45% tint over Gruvbox --chrome-bg #0f1011', () => {
|
||||
// Literal expected values (independently computed: round(0.45·tint + 0.55·bg)),
|
||||
// pixel-identical to what `color-mix(in srgb, tint 45%, #0f1011)` painted.
|
||||
expect([...REGION_COLORS]).toEqual([
|
||||
'rgb(103, 69, 79)',
|
||||
'rgb(67, 83, 78)',
|
||||
'rgb(91, 93, 26)',
|
||||
'rgb(121, 94, 31)',
|
||||
'rgb(72, 95, 65)',
|
||||
'rgb(123, 66, 21)',
|
||||
'rgb(55, 79, 57)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-blends against the new --chrome-bg when [data-theme] changes, and notifies', async () => {
|
||||
const before = getRegionColors();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeRegionColors(() => {
|
||||
notified += 1;
|
||||
});
|
||||
try {
|
||||
root.style.setProperty('--chrome-bg', '#1e293b'); // Slate theme surface
|
||||
root.setAttribute('data-theme', 'slate');
|
||||
await flushThemeObserver();
|
||||
expect(notified).toBe(1);
|
||||
expect(getRegionColors()).not.toBe(before); // fresh snapshot identity
|
||||
// round(0.45·[211,134,155] + 0.55·[30,41,59])
|
||||
expect(REGION_COLORS[0]).toBe('rgb(111, 83, 102)');
|
||||
|
||||
// Back to the default theme (attribute removed, like App.jsx does).
|
||||
root.style.removeProperty('--chrome-bg');
|
||||
root.removeAttribute('data-theme');
|
||||
await flushThemeObserver();
|
||||
expect(notified).toBe(2);
|
||||
expect(REGION_COLORS[0]).toBe('rgb(103, 69, 79)');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses rgb()-form --chrome-bg too, and falls back to #0f1011 on garbage', async () => {
|
||||
root.style.setProperty('--chrome-bg', 'rgb(30, 41, 59)');
|
||||
root.setAttribute('data-theme', 'rgb-form');
|
||||
await flushThemeObserver();
|
||||
expect(REGION_COLORS[0]).toBe('rgb(111, 83, 102)'); // same blend as #1e293b
|
||||
|
||||
root.style.setProperty('--chrome-bg', 'oklch(0.2 0.1 250)'); // unsupported form
|
||||
root.setAttribute('data-theme', 'garbage-form');
|
||||
await flushThemeObserver();
|
||||
expect(REGION_COLORS[0]).toBe('rgb(103, 69, 79)'); // fallback = default blend
|
||||
});
|
||||
|
||||
it('blendRegionColor math: 0.45·tint + 0.55·bg, rounded per channel', () => {
|
||||
expect(blendRegionColor([211, 134, 155], [15, 16, 17])).toBe('rgb(103, 69, 79)');
|
||||
expect(blendRegionColor([0, 0, 0], [255, 255, 255])).toBe('rgb(140, 140, 140)'); // 0.55·255 = 140.25
|
||||
expect(blendRegionColor([255, 255, 255], [0, 0, 0])).toBe('rgb(115, 115, 115)'); // 0.45·255 = 114.75
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user