Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cdn/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# The publishing tool, `cardvision cdn` (the toolkit's docs/cdn.md), with boto3 for the uploads.
cardvision[cdn]==0.10.0
cardvision[cdn]==0.10.1
3 changes: 2 additions & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ in [PRIVACY.md](../PRIVACY.md). Nothing else leaves the browser.
the bundled release on any problem. Frames, pictures and results never go to the network. With a newer release, the
frame also sends each shown card's name to the tooltip frame over a channel the page cannot reach.
- **The tooltip frame** gets a card id and name, loads the card's picture (from the CDN, or none with `namesOnly`), and reports its size back. The
content script places it, and draws the **outline** around the same box.
content script places it, and draws the **outline** around the same box; a card lying at an
angle is outlined, and hovered, along its own edges (the runtime's turned outlines).
- **The details panel:** with card details on, the tooltip frame also loads the card details file
the settings file names (`src/details/`), once, and keeps it in the extension's Cache Storage.
For a card it lists, the frame fills a text panel (as plain text, never markup), measures it at a
Expand Down
8 changes: 6 additions & 2 deletions src/compute/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// rule, the worker and its one restart, the remote release's rules 10 to 12, the messages and the
// pictures it closes. What is DuelSight's own:
// - the options it starts with: its message tag, the pages it serves (`allowedParentOrigin`),
// the shown card held through short gaps (`SHOWN_HOLD`), the release it loads;
// the shown card held through short gaps (`SHOWN_HOLD`), turned cards' outlines
// (`rotatedOutlines`), the release it loads;
// - the card facts channel. A card of a remote release goes to the tooltip frame with its facts
// over an extension-only channel (`postFacts`), never through the page, and they leave before
// the card does. A tooltip frame that lacks a card's facts asks on that channel, and is
Expand Down Expand Up @@ -66,7 +67,7 @@ export function createFactsChannel(postFacts: (message: CardFactsMessage) => voi
}

/** What DuelSight's compute frame starts from: the toolkit's options, less the ones it sets. */
export type DuelSightFrameEnv = Omit<ComputeFrameEnv, "protocol" | "allowedParentOrigin" | "hold" | "onShown" | "parentOrigin"> & {
export type DuelSightFrameEnv = Omit<ComputeFrameEnv, "protocol" | "allowedParentOrigin" | "hold" | "onShown" | "parentOrigin" | "rotatedOutlines"> & {
/** `location.ancestorOrigins[0]`: missing, or the string "null" when a referrer policy hides it. */
parentOrigin: string | null | undefined;
dev: boolean;
Expand All @@ -86,6 +87,9 @@ export function computeFrameEnv(env: DuelSightFrameEnv, facts: FactsChannel): Co
// A card stays shown through short gaps (a live stream's flicker): two analyses in a row with
// nothing there, or 5 s with no analysis seeing it.
hold: SHOWN_HOLD,
// A card lying at an angle is hovered and outlined along its own edges, not its upright box
// (the shown card then carries `rotation`, `localWidth`, `localHeight`; outline.ts draws them).
rotatedOutlines: true,
onShown: facts.onShown,
};
}
Expand Down
12 changes: 7 additions & 5 deletions src/content/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
// compute frame names, placed off the cards around it and scaled down when the page has no
// room for it, and hides it shortly after. Nothing it shows takes the pointer: hovering goes
// on to the card underneath.
// - It outlines the card the tooltip names (outline.ts): the same accepted box, shown and
// hidden with the tooltip, never taking the pointer.
// - It outlines the card the tooltip names (outline.ts): the same accepted box (along the
// card's own edges when it lies at an angle), shown and hidden with the tooltip, never taking
// the pointer.
// - It is on for every video (owner, 2026-10-27: no per-channel switch; the popup's switch is
// the only control). The popup's switch turned off removes the frames again.
// - It takes a message from one of its frames only when the browser delivered it from that
Expand All @@ -28,7 +29,7 @@ import { type CaptureControl, computeProtocol, createCaptureControl, fromExtensi
import { availableSpace, CARD_CORNER, fitBesideCard, fitsBesideCard, fitTooltipSize, fitWithPanel, fractionIn, pictureRect, placeTooltipAvoiding, type Rect, scaleTooltipSize, tooltipRadius } from "./geometry.js";
import { arrange } from "../shared/panel-layout.js";
import { isActivePage } from "./site.js";
import { CardOutline, outlineRect } from "./outline.js";
import { CardOutline, outlinePlacement } from "./outline.js";

/** The two-frame contract under DuelSight's tag; the compute frame uses the same. */
const PROTOCOL = computeProtocol(TAG);
Expand Down Expand Up @@ -447,8 +448,9 @@ export class Agent {
if (this.outline && !this.outline.element.isConnected) {
this.outline = new CardOutline(this.tooltip!.parentElement ?? this.mountHost());
}
// The same box, on the same picture; nothing when the picture's size is not known.
this.outline?.show(ready ? outlineRect(picture, card.box, video.videoWidth > 0 && video.videoHeight > 0) : null);
// The same box, on the same picture (along the card's own edges when it is turned); nothing
// when the picture's size is not known.
this.outline?.show(ready ? outlinePlacement(picture, card, video.videoWidth > 0 && video.videoHeight > 0) : null);
}

private hideTooltip(): void {
Expand Down
46 changes: 43 additions & 3 deletions src/content/outline.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
// The outline drawn around the card whose tooltip is showing. It is the accepted detection's
// box (the one the tooltip is placed beside), mapped onto the picture on screen the same way
// (letterboxing, full screen, theater, resizing). The agent shows and hides it with the tooltip.
// (letterboxing, full screen, theater, resizing). A card lying at an angle is outlined along its
// own edges instead: the shown card's `rotation`, `localWidth` and `localHeight` (the toolkit's
// `rotatedOutlines`), the same outline the hover test uses. The agent shows and hides it with the
// tooltip.
//
// It never takes the pointer and never moves the page: a fixed-position element with
// pointer-events none, nothing focusable, drawn in a closed shadow root so the page's styles do
Expand Down Expand Up @@ -43,6 +46,40 @@ export function outlineRect(picture: Rect, box: FractionBox, pictureKnown = true
return rect.width >= MIN_OUTLINE_SIDE && rect.height >= MIN_OUTLINE_SIDE ? rect : null;
}

/** Where the outline goes, turned: its upright rectangle on screen and the turn about its center
* (degrees, positive clockwise as CSS `rotate()` turns; 0 for an upright card). */
export interface OutlinePlacement extends Rect {
rotation: number;
}

/** The card the outline is drawn for: its box, and its outline when it is turned. */
export interface OutlinedCard {
box: FractionBox;
rotation?: number;
localWidth?: number;
localHeight?: number;
}

/**
* Where the outline goes for `card`. An upright card (no outline fields) is `outlineRect` of its
* box. A turned card is a rectangle of its own sides (`localWidth`, `localHeight`, fractions of
* the picture's width and height) about the center of its box, turned by `rotation`: the toolkit's
* `computeCardQuad`, as CSS draws it. Its box must still pass `outlineRect` (on the picture,
* large enough); a turned outline is not clipped at the picture's edge.
*/
export function outlinePlacement(picture: Rect, card: OutlinedCard, pictureKnown = true): OutlinePlacement | null {
const upright = outlineRect(picture, card.box, pictureKnown);
if (!upright) return null;
const { rotation, localWidth, localHeight } = card;
if (rotation === undefined || localWidth === undefined || localHeight === undefined || rotation === 0) return { ...upright, rotation: 0 };
const width = localWidth * picture.width;
const height = localHeight * picture.height;
if (![rotation, width, height].every(Number.isFinite) || width < MIN_OUTLINE_SIDE || height < MIN_OUTLINE_SIDE) return { ...upright, rotation: 0 };
const cx = picture.left + (card.box.x + card.box.width / 2) * picture.width;
const cy = picture.top + (card.box.y + card.box.height / 2) * picture.height;
return { left: cx - width / 2, top: cy - height / 2, width, height, rotation };
}

/**
* The outline's edge: a solid edge fading into a glow (RiftSight's hover cue, here in a
* card-back navy and a brighter blue), with a faint light hairline inside the edge so a navy
Expand Down Expand Up @@ -93,8 +130,9 @@ export class CardOutline {
return this.visible;
}

/** Draw it around `rect` (page coordinates), or hide it when `rect` is null. */
show(rect: Rect | null): void {
/** Draw it around `rect` (page coordinates), turned about its center by `rect.rotation` when it
* has one, or hide it when `rect` is null. */
show(rect: Rect | OutlinePlacement | null): void {
if (!rect) {
this.hide();
return;
Expand All @@ -104,6 +142,8 @@ export class CardOutline {
s.top = `${rect.top}px`;
s.width = `${rect.width}px`;
s.height = `${rect.height}px`;
const rotation = "rotation" in rect ? rect.rotation : 0;
s.transform = rotation ? `rotate(${rotation}deg)` : "none";
if (this.visible) return;
this.visible = true;
s.display = "block";
Expand Down
33 changes: 32 additions & 1 deletion test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ function fromFrame(frame: HTMLIFrameElement | undefined, data: object): void {

/** The compute frame names a card, and the tooltip frame answers with its size for it (the
* frame is shown only once that size has come). */
function showCard(card: { cardId: string; name: string; box: { x: number; y: number; width: number; height: number } }): void {
function showCard(card: {
cardId: string;
name: string;
box: { x: number; y: number; width: number; height: number };
rotation?: number;
localWidth?: number;
localHeight?: number;
}): void {
fromFrame(compute(), { kind: "shown", card });
fromFrame(tooltip(), { kind: "size", width: 320, height: 448, shape: "card", cardId: card.cardId });
}
Expand Down Expand Up @@ -494,6 +501,30 @@ describe("Agent", () => {
expect(el.style.display).toBe("none");
});

it("outlines a turned card along its own edges, and follows it when only its turn changes", async () => {
vi.useFakeTimers();
agent = await startAgent();
move(100, 100);
const card = { cardId: "89631139", name: "Blue-Eyes White Dragon", box: { x: 0.1, y: 0.1, width: 0.1, height: 0.2 } };
showCard({ ...card, rotation: 15, localWidth: 0.08, localHeight: 0.16 });
const el = outline()!;
expect(el.style.display).toBe("block");
// Box center (144, 108) in page pixels; sides 76.8 x 86.4.
const r = [el.style.left, el.style.top, el.style.width, el.style.height].map(parseFloat);
[105.6, 64.8, 76.8, 86.4].forEach((v, i) => expect(r[i]).toBeCloseTo(v, 3));
expect(el.style.transform).toBe("rotate(15deg)");
showCard({ ...card, rotation: -20, localWidth: 0.08, localHeight: 0.16 });
expect(el.style.transform).toBe("rotate(-20deg)");
// Upright again (no outline fields): the box, unturned.
showCard(card);
expect([el.style.left, el.style.top, el.style.width, el.style.height].map(parseFloat)).toEqual([96, 54, 96, 108]);
expect(el.style.transform).toBe("none");
// A malformed outline is dropped by the parser: the card still shows, upright.
showCard({ ...card, rotation: 90, localWidth: 0.08, localHeight: 0.16 });
expect(el.style.display).toBe("block");
expect(el.style.transform).toBe("none");
});

it("hides the outline with the tooltip when the pointer leaves the player", async () => {
vi.useFakeTimers();
agent = await startAgent();
Expand Down
4 changes: 4 additions & 0 deletions test/compute-frame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ describe("compute frame: the pages it serves", () => {
});

describe("compute frame: the shown card", () => {
it("asks for turned cards' outlines (rotatedOutlines is on)", () => {
expect(computeFrameEnv({ ...computeHarness().env }, createFactsChannel(() => undefined)).rotatedOutlines).toBe(true);
});

it("holds the shown card through short gaps (SHOWN_HOLD is on)", () => {
expect(computeFrameEnv({ ...computeHarness().env }, createFactsChannel(() => undefined)).hold).toBe(SHOWN_HOLD);
const h = computeHarness();
Expand Down
34 changes: 33 additions & 1 deletion test/outline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { describe, expect, it, vi } from "vitest";

import { pictureRect, type Rect } from "../src/content/geometry.js";
import { CardOutline, MIN_OUTLINE_SIDE, OUTLINE_WIDTH, outlineEdgeStyle, outlineRect } from "../src/content/outline.js";
import { CardOutline, MIN_OUTLINE_SIDE, OUTLINE_WIDTH, outlineEdgeStyle, outlinePlacement, outlineRect } from "../src/content/outline.js";
import { OUTLINE_COLOR, OUTLINE_GLOW_COLOR } from "../src/constants.js";

/** Equal to a hundredth of a pixel (fractions do not add up exactly in floating point). */
Expand Down Expand Up @@ -58,6 +58,32 @@ describe("outlineRect", () => {
});
});

describe("outlinePlacement: a turned card", () => {
const picture = { left: 10, top: 20, width: 1000, height: 500 };

it("is outlineRect, unturned, for an upright card", () => {
expect(outlinePlacement(picture, { box })).toEqual({ ...outlineRect(picture, box)!, rotation: 0 });
// Never rotation 0 from the toolkit, but drawn upright if it came.
expect(outlinePlacement(picture, { box, rotation: 0, localWidth: 0.05, localHeight: 0.1 })).toEqual({ ...outlineRect(picture, box)!, rotation: 0 });
});

it("is the card's own sides about the center of its box, turned (computeCardQuad, as CSS draws it)", () => {
// Box center: (10 + 0.3 * 1000, 20 + 0.6 * 500) = (310, 320); sides 80 x 90 px.
const p = outlinePlacement(picture, { box, rotation: 12.5, localWidth: 0.08, localHeight: 0.18 })!;
expectRect(p, { left: 270, top: 275, width: 80, height: 90 });
expect(p.rotation).toBe(12.5);
});

it("draws nothing where the box draws nothing, and falls back to the box for an unusable outline", () => {
expect(outlinePlacement(picture, { box: { x: 1.2, y: 0.1, width: 0.1, height: 0.1 }, rotation: 10, localWidth: 0.05, localHeight: 0.1 })).toBeNull();
expect(outlinePlacement(picture, { box, rotation: 10, localWidth: 0.05, localHeight: 0.1 }, false)).toBeNull();
const upright = { ...outlineRect(picture, box)!, rotation: 0 };
expect(outlinePlacement(picture, { box, rotation: 10, localWidth: 0.001, localHeight: 0.1 })).toEqual(upright);
expect(outlinePlacement(picture, { box, rotation: Number.NaN, localWidth: 0.05, localHeight: 0.1 })).toEqual(upright);
expect(outlinePlacement(picture, { box, rotation: 10, localWidth: 0.05 })).toEqual(upright);
});
});

describe("CardOutline", () => {
it("never takes the pointer, is hidden from assistive tech, and has nothing focusable", () => {
document.body.innerHTML = "";
Expand All @@ -71,6 +97,12 @@ describe("CardOutline", () => {
expect(el.style.display).toBe("none");
outline.show({ left: 10, top: 20, width: 30, height: 40 });
expect([el.style.display, el.style.left, el.style.top, el.style.width, el.style.height]).toEqual(["block", "10px", "20px", "30px", "40px"]);
expect(el.style.transform).toBe("none");
// Turned about its center, and upright again.
outline.show({ left: 10, top: 20, width: 30, height: 40, rotation: -8 });
expect(el.style.transform).toBe("rotate(-8deg)");
outline.show({ left: 10, top: 20, width: 30, height: 40, rotation: 0 });
expect(el.style.transform).toBe("none");
outline.show(null);
expect(el.style.display).toBe("none");
outline.remove();
Expand Down
4 changes: 2 additions & 2 deletions vendor/cardvision-browser/VENDOR.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"version": "0.1.0",
"license": "Apache-2.0",
"source": "cardvision-browser, the browser runtime of the cardvision toolkit (source published with the toolkit release)",
"commit": "d0584b7f6cf1ddc1938ba654f025483768fce429",
"toolkitRelease": "cardvision 0.10.0",
"commit": "8a0f2c269b9df699c38928d9d4da745111469bd7",
"toolkitRelease": "cardvision 0.10.1",
"onnxruntimeWeb": "1.29.0",
"ortFiles": [
"ort-wasm-simd-threaded.jsep.mjs",
Expand Down
22 changes: 11 additions & 11 deletions vendor/cardvision-browser/cardvision.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion vendor/cardvision-browser/compute-session.js

Large diffs are not rendered by default.

Loading
Loading