style: lint
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export interface ArtistrySyncPayload {
|
||||
provider: string
|
||||
globals: any
|
||||
// Card-level defaults to ensure widget triggers respect character settings
|
||||
model?: string
|
||||
promptPrefix?: string
|
||||
options?: Record<string, any>
|
||||
promptPrefix?: string
|
||||
provider: string
|
||||
}
|
||||
export const ARTISTRY_SYNC_CONFIG_ADDRESS = 'eventa:invoke:electron:artistry:sync-config'
|
||||
export const ARTISTRY_TEST_COMFYUI_CONNECTION_ADDRESS = 'eventa:invoke:electron:artistry:test-comfyui-connection'
|
||||
@@ -14,187 +14,187 @@ export const ARTISTRY_TEST_COMFYUI_CONNECTION_ADDRESS = 'eventa:invoke:electron:
|
||||
export const artistrySyncConfig = defineInvokeEventa<void, ArtistrySyncPayload>(ARTISTRY_SYNC_CONFIG_ADDRESS)
|
||||
|
||||
export interface ArtistryTestComfyUIResult {
|
||||
ok: boolean
|
||||
info?: string
|
||||
isCors?: boolean
|
||||
ok: boolean
|
||||
}
|
||||
export const artistryTestComfyUIConnection = defineInvokeEventa<ArtistryTestComfyUIResult, { url: string }>(ARTISTRY_TEST_COMFYUI_CONNECTION_ADDRESS)
|
||||
|
||||
export const artistryGenerateHeadless = defineInvokeEventa<{ imageUrl?: string, base64?: string, error?: string }, { prompt: string, model?: string, provider?: string, options?: Record<string, any>, globals?: Record<string, any> }>('eventa:invoke:electron:artistry:generate-headless')
|
||||
export const artistryGenerateHeadless = defineInvokeEventa<{ base64?: string, error?: string, imageUrl?: string }, { globals?: Record<string, any>, model?: string, options?: Record<string, any>, prompt: string, provider?: string }>('eventa:invoke:electron:artistry:generate-headless')
|
||||
|
||||
export const REPLICATE_IMAGEGEN_PRESETS = [
|
||||
{
|
||||
cost: '$1 / 200 imgs',
|
||||
id: 'prunaai/p-image',
|
||||
label: 'p-image',
|
||||
cost: '$1 / 200 imgs',
|
||||
prompt: 'A high-quality anime-style illustration with professional shading, vibrant colors, hand-drawn aesthetic, highly detailed,',
|
||||
preset: {
|
||||
aspect_ratio: '16:9',
|
||||
},
|
||||
prompt: 'A high-quality anime-style illustration with professional shading, vibrant colors, hand-drawn aesthetic, highly detailed,',
|
||||
},
|
||||
{
|
||||
cost: '$1 / 200 imgs',
|
||||
id: 'prunaai/z-image-turbo',
|
||||
label: 'z-turbo',
|
||||
cost: '$1 / 200 imgs',
|
||||
prompt: 'A highly detailed anime illustration, crisp lines, vibrant color palette, professional digital art style, nicely shaded,',
|
||||
preset: {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
output_format: 'jpg',
|
||||
guidance_scale: 0,
|
||||
output_quality: 80,
|
||||
height: 768,
|
||||
num_inference_steps: 8,
|
||||
output_format: 'jpg',
|
||||
output_quality: 80,
|
||||
width: 1024,
|
||||
},
|
||||
prompt: 'A highly detailed anime illustration, crisp lines, vibrant color palette, professional digital art style, nicely shaded,',
|
||||
},
|
||||
{
|
||||
cost: '$1 / 333 imgs',
|
||||
id: 'black-forest-labs/flux-schnell',
|
||||
label: 'flux-schnell',
|
||||
cost: '$1 / 333 imgs',
|
||||
prompt: 'A stunning, high-definition anime scene, professional cel-shading, vibrant atmosphere, hand-drawn quality,',
|
||||
preset: {
|
||||
aspect_ratio: '1:1',
|
||||
go_fast: true,
|
||||
num_outputs: 1,
|
||||
aspect_ratio: '1:1',
|
||||
output_format: 'webp',
|
||||
output_quality: 80,
|
||||
},
|
||||
prompt: 'A stunning, high-definition anime scene, professional cel-shading, vibrant atmosphere, hand-drawn quality,',
|
||||
},
|
||||
{
|
||||
cost: '$1 / 217 imgs',
|
||||
id: 'prunaai/z-image-turbo-lora:197b2db2015aa366d2bc61a941758adf4c31ac66b18573f5c66dc388ab081ca2',
|
||||
label: 'z-turbo-lora',
|
||||
cost: '$1 / 217 imgs',
|
||||
prompt: 'A beautifully rendered anime illustration in a classic hand-drawn style, rich textures, vibrant colors, masterpiece quality,',
|
||||
preset: {
|
||||
width: 1024,
|
||||
guidance_scale: 0,
|
||||
height: 1024,
|
||||
lora_scales: [1],
|
||||
lora_weights: ['https://huggingface.co/renderartist/Technically-Color-Z-Image-Turbo/resolve/main/Technically_Color_Z_Image_Turbo_v1_renderartist_2000.safetensors'],
|
||||
output_format: 'jpg',
|
||||
guidance_scale: 0,
|
||||
output_quality: 80,
|
||||
num_inference_steps: 8,
|
||||
output_format: 'jpg',
|
||||
output_quality: 80,
|
||||
width: 1024,
|
||||
},
|
||||
prompt: 'A beautifully rendered anime illustration in a classic hand-drawn style, rich textures, vibrant colors, masterpiece quality,',
|
||||
},
|
||||
{
|
||||
cost: '$1 / 151 imgs',
|
||||
id: 'aisha-ai-official/wai-nsfw-illustrious-v11:c1d5b02687df6081c7953c74bcc527858702e8c153c9382012ccc3906752d3ec',
|
||||
label: 'wai-ilx',
|
||||
cost: '$1 / 151 imgs',
|
||||
prompt: 'high quality, masterpiece, hirez, absurdres, anime style, highly detailed, vibrant colors, aesthetic,',
|
||||
preset: {
|
||||
vae: 'default',
|
||||
seed: -1,
|
||||
model: 'WAI-NSFW-illustrious-SDXL-v11',
|
||||
steps: 30,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
batch_size: 1,
|
||||
cfg_scale: 7,
|
||||
clip_skip: 2,
|
||||
pag_scale: 3,
|
||||
scheduler: 'Euler a',
|
||||
batch_size: 1,
|
||||
negative_prompt: 'nsfw, naked',
|
||||
guidance_rescale: 0.5,
|
||||
height: 1024,
|
||||
model: 'WAI-NSFW-illustrious-SDXL-v11',
|
||||
negative_prompt: 'nsfw, naked',
|
||||
pag_scale: 3,
|
||||
prepend_preprompt: true,
|
||||
scheduler: 'Euler a',
|
||||
seed: -1,
|
||||
steps: 30,
|
||||
vae: 'default',
|
||||
width: 1024,
|
||||
},
|
||||
prompt: 'high quality, masterpiece, hirez, absurdres, anime style, highly detailed, vibrant colors, aesthetic,',
|
||||
},
|
||||
{
|
||||
cost: '$1 / 188 imgs',
|
||||
id: 'aisha-ai-official/anillustrious-v4:80441e2c32a55f2fcf9b77fa0a74c6c86ad7deac51eed722b9faedb253265cb4',
|
||||
label: 'anillustrious',
|
||||
cost: '$1 / 188 imgs',
|
||||
prompt: 'high quality, masterpiece, hirez, absurdres, anime style, detailed background, atmospheric, beautifully shaded,',
|
||||
preset: {
|
||||
vae: 'default',
|
||||
seed: -1,
|
||||
model: 'Anillustrious-v4',
|
||||
steps: 30,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
refiner: false,
|
||||
upscale: 'Original',
|
||||
adetailer_face: false,
|
||||
adetailer_face_negative_prompt: '',
|
||||
adetailer_face_prompt: '',
|
||||
adetailer_hand: false,
|
||||
adetailer_hand_negative_prompt: '',
|
||||
adetailer_hand_prompt: '',
|
||||
adetailer_person: false,
|
||||
adetailer_person_negative_prompt: '',
|
||||
adetailer_person_prompt: '',
|
||||
cfg_scale: 7,
|
||||
clip_skip: 2,
|
||||
pag_scale: 0,
|
||||
scheduler: 'Euler a beta',
|
||||
adetailer_face: false,
|
||||
adetailer_hand: false,
|
||||
refiner_prompt: '',
|
||||
negative_prompt: 'nsfw, naked',
|
||||
adetailer_person: false,
|
||||
guidance_rescale: 1,
|
||||
refiner_strength: 0.8,
|
||||
height: 1024,
|
||||
model: 'Anillustrious-v4',
|
||||
negative_prompt: 'nsfw, naked',
|
||||
negative_prompt_conjunction: false,
|
||||
pag_scale: 0,
|
||||
prepend_preprompt: true,
|
||||
prompt_conjunction: true,
|
||||
adetailer_face_prompt: '',
|
||||
adetailer_hand_prompt: '',
|
||||
adetailer_person_prompt: '',
|
||||
negative_prompt_conjunction: false,
|
||||
adetailer_face_negative_prompt: '',
|
||||
adetailer_hand_negative_prompt: '',
|
||||
adetailer_person_negative_prompt: '',
|
||||
refiner: false,
|
||||
refiner_prompt: '',
|
||||
refiner_strength: 0.8,
|
||||
scheduler: 'Euler a beta',
|
||||
seed: -1,
|
||||
steps: 30,
|
||||
upscale: 'Original',
|
||||
vae: 'default',
|
||||
width: 1024,
|
||||
},
|
||||
prompt: 'high quality, masterpiece, hirez, absurdres, anime style, detailed background, atmospheric, beautifully shaded,',
|
||||
},
|
||||
]
|
||||
|
||||
export const REPLICATE_IMAGEEDIT_PRESETS = [
|
||||
{
|
||||
cost: 'Turbo',
|
||||
id: 'prunaai/p-image-edit',
|
||||
label: 'P-Image-Edit (Texture Swapper)',
|
||||
cost: 'Turbo',
|
||||
prompt: 'The woman\'s dress is changed to black',
|
||||
preset: {
|
||||
turbo: true,
|
||||
images: [{ value: '{{IMAGE}}' }],
|
||||
aspect_ratio: '1:1',
|
||||
images: [{ value: '{{IMAGE}}' }],
|
||||
turbo: true,
|
||||
},
|
||||
prompt: 'The woman\'s dress is changed to black',
|
||||
},
|
||||
]
|
||||
export const ARTISTRY_PRESET_GROUPS = [
|
||||
{
|
||||
icon: 'i-solar:palette-bold-duotone',
|
||||
id: 'fabrics',
|
||||
label: 'Fabric Lab',
|
||||
icon: 'i-solar:palette-bold-duotone',
|
||||
presets: [
|
||||
{ id: 'gold', label: 'Gold Leaf', icon: 'i-solar:star-bold-duotone', text: 'Divine Golden transformation. Pure white velvet fabric with thick 24k gold leaf embroidery and glowing white celestial patterns.' },
|
||||
{ id: 'gothic', label: 'Midnight Gothic', icon: 'i-solar:ghost-bold-duotone', text: 'Midnight Gothic style. Deep matte black fabric, crimson lace ruffles, dark leather straps, silver scrollwork embroidery.' },
|
||||
{ id: 'royal', label: 'Royal Porcelain', icon: 'i-solar:crown-minimalistic-bold-duotone', text: 'Royal Porcelain style. White silk base, hand-painted cobalt blue patterns, golden silk sashes, jade ornaments.' },
|
||||
{ id: 'denim', label: 'Raw Indigo Denim', icon: 'i-solar:t-shirt-bold-duotone', text: 'Heavyweight dark indigo denim with thick orange contrast stitching and realistic weathered fading.' },
|
||||
{ id: 'plaid', label: 'Classic Tartan Plaid', icon: 'i-solar:widget-bold-duotone', text: 'Traditional red and green Scottish wool plaid with a visible woven texture and cozy feel.' },
|
||||
{ id: 'satin', label: 'Powder Blue Satin', icon: 'i-solar:water-drops-bold-duotone', text: 'Highly reflective, pale baby blue silk with smooth flowing "liquid" highlights and high luster.' },
|
||||
{ id: 'hex', label: 'Tactical Hex-Grid', icon: 'i-solar:shield-bold-duotone', text: 'Matte olive drab fabric with a subtle hexagonal heat-pressed grid pattern and dark grey utility straps.' },
|
||||
{ id: 'camo', label: 'Cyber Pink Camo', icon: 'i-solar:skateboarding-bold-duotone', text: 'Vibrant hot pink and charcoal grey urban camouflage with a slight tech-fabric sheen.' },
|
||||
{ icon: 'i-solar:star-bold-duotone', id: 'gold', label: 'Gold Leaf', text: 'Divine Golden transformation. Pure white velvet fabric with thick 24k gold leaf embroidery and glowing white celestial patterns.' },
|
||||
{ icon: 'i-solar:ghost-bold-duotone', id: 'gothic', label: 'Midnight Gothic', text: 'Midnight Gothic style. Deep matte black fabric, crimson lace ruffles, dark leather straps, silver scrollwork embroidery.' },
|
||||
{ icon: 'i-solar:crown-minimalistic-bold-duotone', id: 'royal', label: 'Royal Porcelain', text: 'Royal Porcelain style. White silk base, hand-painted cobalt blue patterns, golden silk sashes, jade ornaments.' },
|
||||
{ icon: 'i-solar:t-shirt-bold-duotone', id: 'denim', label: 'Raw Indigo Denim', text: 'Heavyweight dark indigo denim with thick orange contrast stitching and realistic weathered fading.' },
|
||||
{ icon: 'i-solar:widget-bold-duotone', id: 'plaid', label: 'Classic Tartan Plaid', text: 'Traditional red and green Scottish wool plaid with a visible woven texture and cozy feel.' },
|
||||
{ icon: 'i-solar:water-drops-bold-duotone', id: 'satin', label: 'Powder Blue Satin', text: 'Highly reflective, pale baby blue silk with smooth flowing "liquid" highlights and high luster.' },
|
||||
{ icon: 'i-solar:shield-bold-duotone', id: 'hex', label: 'Tactical Hex-Grid', text: 'Matte olive drab fabric with a subtle hexagonal heat-pressed grid pattern and dark grey utility straps.' },
|
||||
{ icon: 'i-solar:skateboarding-bold-duotone', id: 'camo', label: 'Cyber Pink Camo', text: 'Vibrant hot pink and charcoal grey urban camouflage with a slight tech-fabric sheen.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'i-solar:scissors-bold-duotone',
|
||||
id: 'hair',
|
||||
label: 'Hair Salon',
|
||||
icon: 'i-solar:scissors-bold-duotone',
|
||||
presets: [
|
||||
{ id: 'silver', label: 'Iridescent Silver', icon: 'i-solar:snowflake-bold-duotone', text: 'Pure white hair with subtle prismatic "oil-slick" highlights that catch the light.' },
|
||||
{ id: 'onyx', label: 'Onyx Gloss', icon: 'i-solar:moon-bold-duotone', text: 'Pitch black hair with a high-mirror shine and sharp, high-contrast highlights.' },
|
||||
{ id: 'sunset', label: 'Sunset Ombre', icon: 'i-solar:sun-2-bold-duotone', text: 'Vibrant gradient from deep copper roots to fiery orange and golden blonde tips.' },
|
||||
{ id: 'mint', label: 'Ghost Mint', icon: 'i-solar:leaf-bold-duotone', text: 'Soft, matte pastel mint green with a "cloud-like" ethereal texture.' },
|
||||
{ id: 'pink', label: 'Bubblegum Pop', icon: 'i-solar:heart-bold-duotone', text: 'High-gloss, vibrant candy pink with a plastic-like shine and white "rim" highlights.' },
|
||||
{ id: 'rainbow', label: 'Retrowave Rainbow', icon: 'i-solar:filters-bold-duotone', text: 'Multi-colored "raver girl" hair; dark roots with glowing neon streaks of cyan, magenta, and lime green.' },
|
||||
{ icon: 'i-solar:snowflake-bold-duotone', id: 'silver', label: 'Iridescent Silver', text: 'Pure white hair with subtle prismatic "oil-slick" highlights that catch the light.' },
|
||||
{ icon: 'i-solar:moon-bold-duotone', id: 'onyx', label: 'Onyx Gloss', text: 'Pitch black hair with a high-mirror shine and sharp, high-contrast highlights.' },
|
||||
{ icon: 'i-solar:sun-2-bold-duotone', id: 'sunset', label: 'Sunset Ombre', text: 'Vibrant gradient from deep copper roots to fiery orange and golden blonde tips.' },
|
||||
{ icon: 'i-solar:leaf-bold-duotone', id: 'mint', label: 'Ghost Mint', text: 'Soft, matte pastel mint green with a "cloud-like" ethereal texture.' },
|
||||
{ icon: 'i-solar:heart-bold-duotone', id: 'pink', label: 'Bubblegum Pop', text: 'High-gloss, vibrant candy pink with a plastic-like shine and white "rim" highlights.' },
|
||||
{ icon: 'i-solar:filters-bold-duotone', id: 'rainbow', label: 'Retrowave Rainbow', text: 'Multi-colored "raver girl" hair; dark roots with glowing neon streaks of cyan, magenta, and lime green.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'i-solar:eye-bold-duotone',
|
||||
id: 'eyes',
|
||||
label: 'Iris Forge',
|
||||
icon: 'i-solar:eye-bold-duotone',
|
||||
presets: [
|
||||
{ id: 'dragon', label: 'Dragon Slit', icon: 'i-solar:fire-bold-duotone', text: 'Glowing orange irises with vertical black slit pupils and a subtle reptilian texture.' },
|
||||
{ id: 'heart', label: 'Succubus Heart', icon: 'i-solar:heart-angle-bold-duotone', text: 'Soft pink irises with glowing white heart-shaped pupils and a "love-struck" aura.' },
|
||||
{ id: 'star', label: 'Celestial Star', icon: 'i-solar:star-fall-bold-duotone', text: 'Deep violet eyes with white star-shaped pupils and a subtle ring of stardust.' },
|
||||
{ id: 'galaxy', label: 'Nebula Galaxy', icon: 'i-solar:atom-bold-duotone', text: 'Deep space irises containing tiny sparkling stars and purple nebula clusters.' },
|
||||
{ id: 'cyber-eye', label: 'Cyber Scan', icon: 'i-solar:scanner-2-bold-duotone', text: 'Glowing cyan HUD-style eyes with digital scanning rings and data-stream pupils.' },
|
||||
{ icon: 'i-solar:fire-bold-duotone', id: 'dragon', label: 'Dragon Slit', text: 'Glowing orange irises with vertical black slit pupils and a subtle reptilian texture.' },
|
||||
{ icon: 'i-solar:heart-angle-bold-duotone', id: 'heart', label: 'Succubus Heart', text: 'Soft pink irises with glowing white heart-shaped pupils and a "love-struck" aura.' },
|
||||
{ icon: 'i-solar:star-fall-bold-duotone', id: 'star', label: 'Celestial Star', text: 'Deep violet eyes with white star-shaped pupils and a subtle ring of stardust.' },
|
||||
{ icon: 'i-solar:atom-bold-duotone', id: 'galaxy', label: 'Nebula Galaxy', text: 'Deep space irises containing tiny sparkling stars and purple nebula clusters.' },
|
||||
{ icon: 'i-solar:scanner-2-bold-duotone', id: 'cyber-eye', label: 'Cyber Scan', text: 'Glowing cyan HUD-style eyes with digital scanning rings and data-stream pupils.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'i-solar:magic-stick-bold-duotone',
|
||||
id: 'special',
|
||||
label: 'Special Motifs',
|
||||
icon: 'i-solar:magic-stick-bold-duotone',
|
||||
presets: [
|
||||
{ id: 'lotus', label: 'Argent Lotus', icon: 'i-solar:flower-bold-duotone', text: 'The Argent Lotus motif. Translucent white silk petal layers over heavy silver brocade, with delicate silver filigree lotus accents.' },
|
||||
{ icon: 'i-solar:flower-bold-duotone', id: 'lotus', label: 'Argent Lotus', text: 'The Argent Lotus motif. Translucent white silk petal layers over heavy silver brocade, with delicate silver filigree lotus accents.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,6 +17,15 @@ export function base64UrlEncode(buffer: Uint8Array): string {
|
||||
.replace(BASE64_TRAILING_EQ, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a S256 code challenge from a code verifier (RFC 7636 S4.2).
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return base64UrlEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random code verifier (RFC 7636 S4.1).
|
||||
* 43-128 characters from the unreserved URL character set.
|
||||
@@ -27,15 +36,6 @@ export function generateCodeVerifier(length = 64): string {
|
||||
return base64UrlEncode(array)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a S256 code challenge from a code verifier (RFC 7636 S4.2).
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return base64UrlEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random state parameter for CSRF protection.
|
||||
*/
|
||||
|
||||
@@ -24,23 +24,23 @@ import {
|
||||
export const inputAnalyserFFTSize = 1024
|
||||
|
||||
export interface BeatSyncDetector {
|
||||
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
|
||||
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
|
||||
startScreenCapture: () => Promise<void>
|
||||
stop: () => void
|
||||
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => () => void
|
||||
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
|
||||
getInputByteFrequencyData: () => Uint8Array<ArrayBuffer>
|
||||
readonly state: BeatSyncDetectorState
|
||||
readonly context: AudioContext | undefined
|
||||
readonly analyser: Analyser | undefined
|
||||
readonly context: AudioContext | undefined
|
||||
getInputByteFrequencyData: () => Uint8Array<ArrayBuffer>
|
||||
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
|
||||
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => () => void
|
||||
readonly source: AudioNode | undefined
|
||||
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
|
||||
startScreenCapture: () => Promise<void>
|
||||
readonly state: BeatSyncDetectorState
|
||||
stop: () => void
|
||||
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
|
||||
}
|
||||
|
||||
export type CreateBeatSyncDetectorOptions
|
||||
= | { env: StageEnvironment.Tamagotchi }
|
||||
= | { env: StageEnvironment.Capacitor }
|
||||
| { env: StageEnvironment.Tamagotchi }
|
||||
| { env: StageEnvironment.Web }
|
||||
| { env: StageEnvironment.Capacitor }
|
||||
|
||||
export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): BeatSyncDetector {
|
||||
let context: AudioContext | undefined
|
||||
@@ -56,8 +56,8 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
|
||||
let inputAnalyserBuffer: Uint8Array<ArrayBuffer> | undefined
|
||||
|
||||
const listeners: { [K in keyof BeatSyncDetectorEventMap]: Array<(...args: any) => void> } = {
|
||||
stateChange: [],
|
||||
beat: [],
|
||||
stateChange: [],
|
||||
}
|
||||
|
||||
const emit = <E extends keyof BeatSyncDetectorEventMap>(event: E, ...args: Parameters<BeatSyncDetectorEventMap[E]>) => {
|
||||
@@ -95,10 +95,10 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
|
||||
context = new AudioContext()
|
||||
analyser = await startTemporaAnalyser({
|
||||
context,
|
||||
worklet: analyserWorklet,
|
||||
listeners: {
|
||||
onBeat: e => emit('beat', e),
|
||||
},
|
||||
worklet: analyserWorklet,
|
||||
})
|
||||
|
||||
const node = await createSource(context)
|
||||
@@ -124,12 +124,48 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
|
||||
|
||||
const startScreenCapture = async () => start(async (ctx) => {
|
||||
switch (options.env) {
|
||||
case StageEnvironment.Tamagotchi: {
|
||||
if (!isElectronWindow(window)) {
|
||||
throw new Error(`Electron window is required for this environment: ${options.env}`)
|
||||
}
|
||||
|
||||
// FIXME(Makito): Will refactor later
|
||||
const { createContext } = await import('@moeru/eventa/adapters/electron/renderer')
|
||||
const { selectWithSource } = setupElectronScreenCapture(createContext(window.electron.ipcRenderer).context)
|
||||
|
||||
const stream = await selectWithSource(
|
||||
(sources: SerializableDesktopCapturerSource[]) => {
|
||||
if (sources.length === 0)
|
||||
throw new Error('No screen source available')
|
||||
return sources[0].id
|
||||
},
|
||||
async () => await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: true,
|
||||
video: true,
|
||||
}),
|
||||
{ sourcesOptions: { types: ['screen'] } },
|
||||
)
|
||||
|
||||
const videoTracks = stream.getVideoTracks()
|
||||
|
||||
videoTracks.forEach((track: MediaStreamTrack) => {
|
||||
track.stop()
|
||||
stream.removeTrack(track)
|
||||
})
|
||||
|
||||
const node = ctx.createMediaStreamSource(stream)
|
||||
stopSource = () => {
|
||||
stream.getTracks().forEach((track: MediaStreamTrack) => track.stop())
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
case StageEnvironment.Web: {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: {
|
||||
autoGainControl: false,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: true,
|
||||
})
|
||||
@@ -155,42 +191,6 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
|
||||
|
||||
return node
|
||||
}
|
||||
case StageEnvironment.Tamagotchi: {
|
||||
if (!isElectronWindow(window)) {
|
||||
throw new Error(`Electron window is required for this environment: ${options.env}`)
|
||||
}
|
||||
|
||||
// FIXME(Makito): Will refactor later
|
||||
const { createContext } = await import('@moeru/eventa/adapters/electron/renderer')
|
||||
const { selectWithSource } = setupElectronScreenCapture(createContext(window.electron.ipcRenderer).context)
|
||||
|
||||
const stream = await selectWithSource(
|
||||
(sources: SerializableDesktopCapturerSource[]) => {
|
||||
if (sources.length === 0)
|
||||
throw new Error('No screen source available')
|
||||
return sources[0].id
|
||||
},
|
||||
async () => await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
}),
|
||||
{ sourcesOptions: { types: ['screen'] } },
|
||||
)
|
||||
|
||||
const videoTracks = stream.getVideoTracks()
|
||||
|
||||
videoTracks.forEach((track: MediaStreamTrack) => {
|
||||
track.stop()
|
||||
stream.removeTrack(track)
|
||||
})
|
||||
|
||||
const node = ctx.createMediaStreamSource(stream)
|
||||
stopSource = () => {
|
||||
stream.getTracks().forEach((track: MediaStreamTrack) => track.stop())
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
default:
|
||||
throw new Error('Failed to start screen capture: Unsupported environment')
|
||||
}
|
||||
@@ -222,18 +222,18 @@ export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions):
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
updateParameters,
|
||||
startScreenCapture,
|
||||
stop,
|
||||
on,
|
||||
off,
|
||||
getInputByteFrequencyData,
|
||||
|
||||
get state() { return state },
|
||||
get context() { return context },
|
||||
get analyser() { return analyser },
|
||||
get context() { return context },
|
||||
getInputByteFrequencyData,
|
||||
off,
|
||||
on,
|
||||
get source() { return source },
|
||||
start,
|
||||
|
||||
startScreenCapture,
|
||||
get state() { return state },
|
||||
stop,
|
||||
updateParameters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,29 +250,28 @@ function getDetector() {
|
||||
|
||||
let context: ReturnType<typeof createContext> | undefined
|
||||
|
||||
function installCapacitorHandlers(context: ReturnType<typeof createContext>) {
|
||||
defineInvokeHandler(context, beatSyncToggleInvokeEventa, async (enabled) => {
|
||||
if (enabled)
|
||||
throw new Error('Beat Sync is not available in Stage Pocket')
|
||||
})
|
||||
defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => ({ isActive: false }))
|
||||
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async () => {})
|
||||
defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => {
|
||||
return new Uint8Array(inputAnalyserFFTSize / 2)
|
||||
})
|
||||
}
|
||||
|
||||
function getContext() {
|
||||
if (!context) {
|
||||
context = createContext()
|
||||
|
||||
// Capacitor cannot capture system audio. Register every request handler so
|
||||
// callers receive deterministic inactive results instead of pending forever.
|
||||
if (isStageCapacitor())
|
||||
installCapacitorHandlers(context)
|
||||
export async function getBeatSyncInputByteFrequencyData() {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().getInputByteFrequencyData()
|
||||
}
|
||||
|
||||
return context
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvoke(getContext(), beatSyncGetInputByteFrequencyDataInvokeEventa)()
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for getBeatSyncInputByteFrequencyData()')
|
||||
}
|
||||
|
||||
export async function getBeatSyncState() {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().state
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvoke(getContext(), beatSyncGetStateInvokeEventa)()
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for getBeatSyncState()')
|
||||
}
|
||||
|
||||
/** Returns whether the current Stage runtime can capture audio for Beat Sync. */
|
||||
@@ -280,6 +279,30 @@ export function isBeatSyncSupported() {
|
||||
return isStageWeb() || isStageTamagotchi()
|
||||
}
|
||||
|
||||
export function listenBeatSyncBeatSignal(listener: (e: AnalyserBeatEvent) => void) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().on('beat', listener)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvokeHandler(getContext(), beatSyncBeatSignaledInvokeEventa, listener)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for listenBeatSyncBeatSignal()')
|
||||
}
|
||||
|
||||
export function listenBeatSyncStateChange(listener: (state: BeatSyncDetectorState) => void) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().on('stateChange', listener)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvokeHandler(getContext(), beatSyncStateChangedInvokeEventa, listener)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for listenBeatSyncStateChange()')
|
||||
}
|
||||
|
||||
export function toggleBeatSync(enabled: boolean) {
|
||||
if (isStageWeb()) {
|
||||
if (enabled) {
|
||||
@@ -298,18 +321,6 @@ export function toggleBeatSync(enabled: boolean) {
|
||||
throw new Error('Unknown environment for beatSyncToggle()')
|
||||
}
|
||||
|
||||
export async function getBeatSyncState() {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().state
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvoke(getContext(), beatSyncGetStateInvokeEventa)()
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for getBeatSyncState()')
|
||||
}
|
||||
|
||||
export function updateBeatSyncParameters(params: Partial<AnalyserWorkletParameters>) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().updateParameters(params)
|
||||
@@ -322,38 +333,27 @@ export function updateBeatSyncParameters(params: Partial<AnalyserWorkletParamete
|
||||
throw new Error('Unknown environment for updateBeatSyncParameters()')
|
||||
}
|
||||
|
||||
export function listenBeatSyncStateChange(listener: (state: BeatSyncDetectorState) => void) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().on('stateChange', listener)
|
||||
function getContext() {
|
||||
if (!context) {
|
||||
context = createContext()
|
||||
|
||||
// Capacitor cannot capture system audio. Register every request handler so
|
||||
// callers receive deterministic inactive results instead of pending forever.
|
||||
if (isStageCapacitor())
|
||||
installCapacitorHandlers(context)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvokeHandler(getContext(), beatSyncStateChangedInvokeEventa, listener)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for listenBeatSyncStateChange()')
|
||||
return context
|
||||
}
|
||||
|
||||
export function listenBeatSyncBeatSignal(listener: (e: AnalyserBeatEvent) => void) {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().on('beat', listener)
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvokeHandler(getContext(), beatSyncBeatSignaledInvokeEventa, listener)
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for listenBeatSyncBeatSignal()')
|
||||
}
|
||||
|
||||
export async function getBeatSyncInputByteFrequencyData() {
|
||||
if (isStageWeb()) {
|
||||
return getDetector().getInputByteFrequencyData()
|
||||
}
|
||||
|
||||
if (isStageTamagotchi() || isStageCapacitor()) {
|
||||
return defineInvoke(getContext(), beatSyncGetInputByteFrequencyDataInvokeEventa)()
|
||||
}
|
||||
|
||||
throw new Error('Unknown environment for getBeatSyncInputByteFrequencyData()')
|
||||
function installCapacitorHandlers(context: ReturnType<typeof createContext>) {
|
||||
defineInvokeHandler(context, beatSyncToggleInvokeEventa, async (enabled) => {
|
||||
if (enabled)
|
||||
throw new Error('Beat Sync is not available in Stage Pocket')
|
||||
})
|
||||
defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => ({ isActive: false }))
|
||||
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async () => {})
|
||||
defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => {
|
||||
return new Uint8Array(inputAnalyserFFTSize / 2)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ export const beatSyncStateChangedInvokeEventa = defineInvokeEventa<void, BeatSyn
|
||||
export const beatSyncBeatSignaledInvokeEventa = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:beat-signaled')
|
||||
|
||||
let _broadcastChannel: BroadcastChannel | undefined
|
||||
export function createContext(): InvocableEventContext<any, { raw?: any }> {
|
||||
if (isElectronWindow(window)) {
|
||||
return createBroadcastChannelContext(getBroadcastChannel()).context as InvocableEventContext<any, { raw?: any }>
|
||||
}
|
||||
else {
|
||||
return createWebContext()
|
||||
}
|
||||
}
|
||||
|
||||
function getBroadcastChannel() {
|
||||
if (!_broadcastChannel) {
|
||||
_broadcastChannel = new BroadcastChannel('airi::beat-sync')
|
||||
@@ -28,12 +37,3 @@ function getBroadcastChannel() {
|
||||
}
|
||||
return _broadcastChannel
|
||||
}
|
||||
|
||||
export function createContext(): InvocableEventContext<any, { raw?: any }> {
|
||||
if (isElectronWindow(window)) {
|
||||
return createBroadcastChannelContext(getBroadcastChannel()).context as InvocableEventContext<any, { raw?: any }>
|
||||
}
|
||||
else {
|
||||
return createWebContext()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ export type BeatSyncSpectrumScale = 'linear' | 'logarithm'
|
||||
|
||||
export const DEFAULT_BEAT_SYNC_PARAMETERS: AnalyserWorkletParameters = {
|
||||
...DEFAULT_ANALYSER_WORKLET_PARAMS,
|
||||
adaptiveThreshold: false,
|
||||
spectralFlux: false,
|
||||
// Loosen the parameters for easier beat detection by default.
|
||||
// Also makes life easier :)
|
||||
warmup: false,
|
||||
spectralFlux: false,
|
||||
adaptiveThreshold: false,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AnalyserBeatEvent } from '@nekopaw/tempora'
|
||||
|
||||
export interface BeatSyncDetectorEventMap {
|
||||
beat: (e: AnalyserBeatEvent) => void
|
||||
stateChange: (state: BeatSyncDetectorState) => void
|
||||
}
|
||||
|
||||
export interface BeatSyncDetectorState {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface BeatSyncDetectorEventMap {
|
||||
stateChange: (state: BeatSyncDetectorState) => void
|
||||
beat: (e: AnalyserBeatEvent) => void
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ export interface CaptionChannelEvent {
|
||||
/** Text rendered by the caption overlay. Empty text clears this source. */
|
||||
text: string
|
||||
/** Identifies the speaker that owns this caption text. */
|
||||
type: 'caption-speaker' | 'caption-assistant'
|
||||
type: 'caption-assistant' | 'caption-speaker'
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useLocalStorageManualReset<T>(
|
||||
const localStorageState = useLocalStorage<T>(key, value, options)
|
||||
const state = refManualReset<T>(localStorageState)
|
||||
|
||||
const { resume, pause } = watch(state, newValue => localStorageState.value = newValue, options)
|
||||
const { pause, resume } = watch(state, newValue => localStorageState.value = newValue, options)
|
||||
if (options?.listenToStorageChanges !== false) {
|
||||
watch(localStorageState, (newValue) => {
|
||||
// Writing state to useStorage updates this ref with the same value. A
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export function useVersionedLocalStorageManualReset<T>(
|
||||
const localStorageState = useVersionedLocalStorage<T>(key, value, options)
|
||||
const state = refManualReset<T>(localStorageState)
|
||||
|
||||
const { resume, pause } = watch(state, newValue => localStorageState.value = newValue, options)
|
||||
const { pause, resume } = watch(state, newValue => localStorageState.value = newValue, options)
|
||||
watch(localStorageState, (newValue) => {
|
||||
pause()
|
||||
state.value = newValue
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('useVersionedLocalStorage', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(JSON.parse(storage.getItem('settings/live2d/auto-blink-enabled')!)).toEqual({
|
||||
version: '2.0.0',
|
||||
data: false,
|
||||
version: '2.0.0',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,17 +4,17 @@ import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
import { defaultWindow, useStorage } from '@vueuse/core'
|
||||
import { ref, toValue, watch } from 'vue'
|
||||
|
||||
export interface Versioned<T> { version?: string, data?: T }
|
||||
export type OnVersionMismatchActions<T> = OnVersionMismatchKeep<T> | OnVersionMismatchReset<T>
|
||||
export interface OnVersionMismatchKeep<T> { action: 'keep', data?: T }
|
||||
|
||||
export interface OnVersionMismatchReset<T> { action: 'reset', data?: T }
|
||||
export interface UseVersionedStorageOptions<T> {
|
||||
defaultVersion?: string
|
||||
storage?: StorageLike
|
||||
satisfiesVersionBy?: (beforeVersion: string, afterVersion: string) => boolean
|
||||
onVersionMismatch?: (value: Versioned<T>) => OnVersionMismatchActions<T>
|
||||
satisfiesVersionBy?: (beforeVersion: string, afterVersion: string) => boolean
|
||||
storage?: StorageLike
|
||||
}
|
||||
|
||||
export interface OnVersionMismatchKeep<T> { action: 'keep', data?: T }
|
||||
export interface OnVersionMismatchReset<T> { action: 'reset', data?: T }
|
||||
export type OnVersionMismatchActions<T> = OnVersionMismatchKeep<T> | OnVersionMismatchReset<T>
|
||||
export interface Versioned<T> { data?: T, version?: string }
|
||||
|
||||
export function useVersionedLocalStorage<T>(
|
||||
key: MaybeRefOrGetter<string>,
|
||||
@@ -25,13 +25,13 @@ export function useVersionedLocalStorage<T>(
|
||||
const data = ref(toValue(initialValue)) as Ref<T, T>
|
||||
const rawValue = useStorage<Versioned<T>>(
|
||||
key,
|
||||
{ version: defaultVersion, data: toValue(initialValue) },
|
||||
{ data: toValue(initialValue), version: defaultVersion },
|
||||
options?.storage ?? defaultWindow?.localStorage,
|
||||
options as unknown as UseStorageOptions<Versioned<T>>,
|
||||
)
|
||||
|
||||
const syncDataToStorage = watch(data, (value) => {
|
||||
rawValue.value = { version: defaultVersion, data: value }
|
||||
rawValue.value = { data: value, version: defaultVersion }
|
||||
}, {
|
||||
deep: true,
|
||||
})
|
||||
@@ -43,7 +43,7 @@ export function useVersionedLocalStorage<T>(
|
||||
if (options.onVersionMismatch != null) {
|
||||
const action = options.onVersionMismatch(rawValue.value)
|
||||
if (action.action === 'reset') {
|
||||
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
|
||||
rawValue.value = { data: toValue(initialValue), version: defaultVersion }
|
||||
syncDataToStorage.pause()
|
||||
data.value = toValue(initialValue)
|
||||
syncDataToStorage.resume()
|
||||
@@ -51,7 +51,7 @@ export function useVersionedLocalStorage<T>(
|
||||
}
|
||||
else {
|
||||
console.warn(`version ${rawValue.value.version} doesn't satisfy the version ${defaultVersion} for key ${key}, will reset the value to default value ${toValue(initialValue)}`)
|
||||
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
|
||||
rawValue.value = { data: toValue(initialValue), version: defaultVersion }
|
||||
syncDataToStorage.pause()
|
||||
data.value = toValue(initialValue)
|
||||
syncDataToStorage.resume()
|
||||
@@ -65,21 +65,21 @@ export function useVersionedLocalStorage<T>(
|
||||
}
|
||||
|
||||
console.warn(`property key 'version' wasn't found in the value of key ${key} as ${value}, will keep the current ${toValue(initialValue)}`)
|
||||
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
|
||||
rawValue.value = { data: toValue(initialValue), version: defaultVersion }
|
||||
syncDataToStorage.pause()
|
||||
data.value = toValue(initialValue)
|
||||
syncDataToStorage.resume()
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`failed to un-marshal Local Storage value, possibly due to incompatible or corrupted for key ${key} value ${value}, falling back to default value ${toValue(initialValue)}`, err)
|
||||
rawValue.value = { version: defaultVersion, data: toValue(initialValue) }
|
||||
rawValue.value = { data: toValue(initialValue), version: defaultVersion }
|
||||
syncDataToStorage.pause()
|
||||
data.value = toValue(initialValue)
|
||||
syncDataToStorage.resume()
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export function isCustomProvidersDisabled(): boolean {
|
||||
return isEnvTruthy(import.meta.env.VITE_DISABLE_CUSTOM_PROVIDERS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the provided environment variable represents a truthy value.
|
||||
*
|
||||
* Truthy values: `true`, `t`, `yes`, `y`, `on`, `1`
|
||||
*/
|
||||
export function isEnvTruthy(value: string | undefined | null): boolean {
|
||||
export function isEnvTruthy(value: null | string | undefined): boolean {
|
||||
if (value == null)
|
||||
return false
|
||||
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
|
||||
@@ -12,7 +16,3 @@ export function isEnvTruthy(value: string | undefined | null): boolean {
|
||||
export function isFluxPurchaseDisabled(): boolean {
|
||||
return isEnvTruthy(import.meta.env.VITE_DISABLE_FLUX_PURCHASE)
|
||||
}
|
||||
|
||||
export function isCustomProvidersDisabled(): boolean {
|
||||
return isEnvTruthy(import.meta.env.VITE_DISABLE_CUSTOM_PROVIDERS)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export enum StageEnvironment {
|
||||
Web = 'web',
|
||||
Capacitor = 'capacitor',
|
||||
Tamagotchi = 'tamagotchi',
|
||||
Web = 'web',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10,18 +10,6 @@ export enum StageEnvironment {
|
||||
*/
|
||||
export const IS_DEV: boolean = import.meta.env.DEV
|
||||
|
||||
export function isStageWeb(): boolean {
|
||||
return !import.meta.env.RUNTIME_ENVIRONMENT || import.meta.env.RUNTIME_ENVIRONMENT === 'browser'
|
||||
}
|
||||
|
||||
export function isStageCapacitor(): boolean {
|
||||
return import.meta.env.RUNTIME_ENVIRONMENT === 'capacitor'
|
||||
}
|
||||
|
||||
export function isStageTamagotchi(): boolean {
|
||||
return import.meta.env.RUNTIME_ENVIRONMENT === 'electron'
|
||||
}
|
||||
|
||||
export function getStage() {
|
||||
if (isStageTamagotchi())
|
||||
return StageEnvironment.Tamagotchi
|
||||
@@ -32,6 +20,18 @@ export function getStage() {
|
||||
return StageEnvironment.Web
|
||||
}
|
||||
|
||||
export function isStageCapacitor(): boolean {
|
||||
return import.meta.env.RUNTIME_ENVIRONMENT === 'capacitor'
|
||||
}
|
||||
|
||||
export function isStageTamagotchi(): boolean {
|
||||
return import.meta.env.RUNTIME_ENVIRONMENT === 'electron'
|
||||
}
|
||||
|
||||
export function isStageWeb(): boolean {
|
||||
return !import.meta.env.RUNTIME_ENVIRONMENT || import.meta.env.RUNTIME_ENVIRONMENT === 'browser'
|
||||
}
|
||||
|
||||
export function isUrlMode(mode: 'file' | 'server'): boolean {
|
||||
if (!import.meta.env.URL_MODE) {
|
||||
return mode === 'server'
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
function quoteField(field: unknown): string {
|
||||
return `"${String(field).replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toCsv(rows: Array<Array<unknown>>): string {
|
||||
return rows
|
||||
.map(row => row.map(quoteField).join(','))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function exportCsv(rows: Array<Array<unknown>>, basename: string) {
|
||||
if (!rows.length)
|
||||
return
|
||||
@@ -26,3 +16,13 @@ export function exportCsv(rows: Array<Array<unknown>>, basename: string) {
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function quoteField(field: unknown): string {
|
||||
return `"${String(field).replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toCsv(rows: Array<Array<unknown>>): string {
|
||||
return rows
|
||||
.map(row => row.map(quoteField).join(','))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
describe('parseAccelerator', () => {
|
||||
it('parses a single key with no modifiers', () => {
|
||||
// @example "Escape" — bare key, valid on its own
|
||||
expect(parseAccelerator('Escape')).toEqual({ modifiers: [], key: 'Escape' })
|
||||
expect(parseAccelerator('Escape')).toEqual({ key: 'Escape', modifiers: [] })
|
||||
})
|
||||
|
||||
it('parses a typical accelerator with modifiers', () => {
|
||||
// @example "Mod+Shift+K" — common Cmd/Ctrl shortcut
|
||||
expect(parseAccelerator('Mod+Shift+K'))
|
||||
.toEqual({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' })
|
||||
.toEqual({ key: 'KeyK', modifiers: ['cmd-or-ctrl', 'shift'] })
|
||||
})
|
||||
|
||||
it('treats Mod, CmdOrCtrl, and CommandOrControl as the same modifier', () => {
|
||||
@@ -135,57 +135,57 @@ describe('formatAccelerator', () => {
|
||||
it('emits canonical IR with modifier ordering normalized', () => {
|
||||
// @example modifiers given in author order ['shift', 'cmd-or-ctrl']
|
||||
// serialize as 'Mod+Shift+KeyK', not 'Shift+Mod+KeyK'
|
||||
expect(formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' }))
|
||||
expect(formatAccelerator({ key: 'KeyK', modifiers: ['shift', 'cmd-or-ctrl'] }))
|
||||
.toBe('Mod+Shift+KeyK')
|
||||
})
|
||||
|
||||
it('emits a bare key when there are no modifiers', () => {
|
||||
expect(formatAccelerator({ modifiers: [], key: 'Escape' })).toBe('Escape')
|
||||
expect(formatAccelerator({ key: 'Escape', modifiers: [] })).toBe('Escape')
|
||||
})
|
||||
|
||||
it('orders modifiers as cmd-or-ctrl, cmd, ctrl, alt, shift, super', () => {
|
||||
expect(formatAccelerator({
|
||||
modifiers: ['super', 'shift', 'alt', 'ctrl', 'cmd', 'cmd-or-ctrl'],
|
||||
key: 'KeyK',
|
||||
modifiers: ['super', 'shift', 'alt', 'ctrl', 'cmd', 'cmd-or-ctrl'],
|
||||
})).toBe('Mod+Cmd+Ctrl+Alt+Shift+Super+KeyK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatElectronAccelerator', () => {
|
||||
it('rewrites cmd-or-ctrl to CmdOrCtrl', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' }))
|
||||
expect(formatElectronAccelerator({ key: 'KeyK', modifiers: ['cmd-or-ctrl', 'shift'] }))
|
||||
.toBe('CmdOrCtrl+Shift+K')
|
||||
})
|
||||
|
||||
it('strips the Key prefix from letter keys', () => {
|
||||
// @example "KeyK" -> "K"
|
||||
expect(formatElectronAccelerator({ modifiers: ['alt'], key: 'KeyA' }))
|
||||
expect(formatElectronAccelerator({ key: 'KeyA', modifiers: ['alt'] }))
|
||||
.toBe('Alt+A')
|
||||
})
|
||||
|
||||
it('strips the Digit prefix from digit keys', () => {
|
||||
// @example "Digit1" -> "1"
|
||||
expect(formatElectronAccelerator({ modifiers: ['cmd-or-ctrl'], key: 'Digit1' }))
|
||||
expect(formatElectronAccelerator({ key: 'Digit1', modifiers: ['cmd-or-ctrl'] }))
|
||||
.toBe('CmdOrCtrl+1')
|
||||
})
|
||||
|
||||
it('rewrites arrow keys to Electron short names', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'ArrowUp' })).toBe('Up')
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'ArrowDown' })).toBe('Down')
|
||||
expect(formatElectronAccelerator({ key: 'ArrowUp', modifiers: [] })).toBe('Up')
|
||||
expect(formatElectronAccelerator({ key: 'ArrowDown', modifiers: [] })).toBe('Down')
|
||||
})
|
||||
|
||||
it('rewrites Escape to Esc', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'Escape' })).toBe('Esc')
|
||||
expect(formatElectronAccelerator({ key: 'Escape', modifiers: [] })).toBe('Esc')
|
||||
})
|
||||
|
||||
it('rewrites punctuation keys to literal characters', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'Equal' })).toBe('=')
|
||||
expect(formatElectronAccelerator({ modifiers: ['shift'], key: 'Slash' })).toBe('Shift+/')
|
||||
expect(formatElectronAccelerator({ key: 'Equal', modifiers: [] })).toBe('=')
|
||||
expect(formatElectronAccelerator({ key: 'Slash', modifiers: ['shift'] })).toBe('Shift+/')
|
||||
})
|
||||
|
||||
it('passes function and named keys through unchanged', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: ['alt'], key: 'F12' })).toBe('Alt+F12')
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'Space' })).toBe('Space')
|
||||
expect(formatElectronAccelerator({ key: 'F12', modifiers: ['alt'] })).toBe('Alt+F12')
|
||||
expect(formatElectronAccelerator({ key: 'Space', modifiers: [] })).toBe('Space')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -89,32 +89,32 @@ const FUNCTION_KEYS: ReadonlySet<ShortcutKey> = new Set([
|
||||
])
|
||||
|
||||
const NAMED_KEYS: ReadonlySet<ShortcutKey> = new Set([
|
||||
'Space',
|
||||
'Tab',
|
||||
'Enter',
|
||||
'Escape',
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Insert',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'ArrowUp',
|
||||
'Backquote',
|
||||
'Minus',
|
||||
'Equal',
|
||||
'Backslash',
|
||||
'Backspace',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Backslash',
|
||||
'Semicolon',
|
||||
'Quote',
|
||||
'Comma',
|
||||
'Delete',
|
||||
'End',
|
||||
'Enter',
|
||||
'Equal',
|
||||
'Escape',
|
||||
'Home',
|
||||
'Insert',
|
||||
'Minus',
|
||||
'PageDown',
|
||||
'PageUp',
|
||||
'Period',
|
||||
'Quote',
|
||||
'Semicolon',
|
||||
'Slash',
|
||||
'Space',
|
||||
'Tab',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -137,13 +137,13 @@ export const KEY_NAMES: ReadonlySet<ShortcutKey> = new Set<ShortcutKey>([
|
||||
* and `1` becomes `Digit1` without lookup.
|
||||
*/
|
||||
const KEY_ALIASES: ReadonlyMap<string, ShortcutKey> = new Map([
|
||||
['Up', 'ArrowUp'],
|
||||
['Down', 'ArrowDown'],
|
||||
['Left', 'ArrowLeft'],
|
||||
['Right', 'ArrowRight'],
|
||||
['Esc', 'Escape'],
|
||||
['Left', 'ArrowLeft'],
|
||||
['Return', 'Enter'],
|
||||
['Right', 'ArrowRight'],
|
||||
['Spacebar', 'Space'],
|
||||
['Up', 'ArrowUp'],
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -153,14 +153,14 @@ const KEY_ALIASES: ReadonlyMap<string, ShortcutKey> = new Map([
|
||||
* style so authors can paste from existing docs without translation.
|
||||
*/
|
||||
const MODIFIER_ALIASES: ReadonlyMap<string, ShortcutModifier> = new Map<string, ShortcutModifier>([
|
||||
['mod', 'cmd-or-ctrl'],
|
||||
['cmdorctrl', 'cmd-or-ctrl'],
|
||||
['commandorcontrol', 'cmd-or-ctrl'],
|
||||
['cmd', 'cmd'],
|
||||
['command', 'cmd'],
|
||||
['ctrl', 'ctrl'],
|
||||
['control', 'ctrl'],
|
||||
['alt', 'alt'],
|
||||
['cmd', 'cmd'],
|
||||
['cmdorctrl', 'cmd-or-ctrl'],
|
||||
['command', 'cmd'],
|
||||
['commandorcontrol', 'cmd-or-ctrl'],
|
||||
['control', 'ctrl'],
|
||||
['ctrl', 'ctrl'],
|
||||
['mod', 'cmd-or-ctrl'],
|
||||
['option', 'alt'],
|
||||
['shift', 'shift'],
|
||||
['super', 'super'],
|
||||
@@ -187,10 +187,10 @@ const MODIFIER_CANONICAL_ORDER: readonly ShortcutModifier[] = [
|
||||
* recognizable.
|
||||
*/
|
||||
const MODIFIER_TO_CANONICAL_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
'cmd-or-ctrl': 'Mod',
|
||||
'cmd': 'Cmd',
|
||||
'ctrl': 'Ctrl',
|
||||
'alt': 'Alt',
|
||||
'cmd': 'Cmd',
|
||||
'cmd-or-ctrl': 'Mod',
|
||||
'ctrl': 'Ctrl',
|
||||
'shift': 'Shift',
|
||||
'super': 'Super',
|
||||
}
|
||||
@@ -200,10 +200,10 @@ const MODIFIER_TO_CANONICAL_TOKEN: Readonly<Record<ShortcutModifier, string>> =
|
||||
* `CmdOrCtrl`; everything else passes through unchanged.
|
||||
*/
|
||||
const MODIFIER_TO_ELECTRON_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
'cmd-or-ctrl': 'CmdOrCtrl',
|
||||
'cmd': 'Cmd',
|
||||
'ctrl': 'Ctrl',
|
||||
'alt': 'Alt',
|
||||
'cmd': 'Cmd',
|
||||
'cmd-or-ctrl': 'CmdOrCtrl',
|
||||
'ctrl': 'Ctrl',
|
||||
'shift': 'Shift',
|
||||
'super': 'Super',
|
||||
}
|
||||
@@ -217,21 +217,21 @@ const MODIFIER_TO_ELECTRON_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
* prefix in `toElectronKey`, so they are not listed here.
|
||||
*/
|
||||
const ELECTRON_KEY_OVERRIDES: Readonly<Record<string, string>> = {
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
Escape: 'Esc',
|
||||
ArrowUp: 'Up',
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: '\'',
|
||||
Comma: ',',
|
||||
Equal: '=',
|
||||
Escape: 'Esc',
|
||||
Minus: '-',
|
||||
Period: '.',
|
||||
Quote: '\'',
|
||||
Semicolon: ';',
|
||||
Slash: '/',
|
||||
}
|
||||
|
||||
@@ -239,43 +239,68 @@ const SINGLE_LETTER_RE = /^[A-Z]$/i
|
||||
const SINGLE_DIGIT_RE = /^\d$/
|
||||
|
||||
/**
|
||||
* Normalizes a raw key token to a canonical `ShortcutKey`.
|
||||
* Serializes a structured accelerator back to its canonical string
|
||||
* form.
|
||||
*
|
||||
* Before:
|
||||
* - `"K"` / `"k"` / `"KeyK"` / `"Up"` / `"Esc"`
|
||||
* Use when:
|
||||
* - Displaying a binding in settings UI
|
||||
* - Round-tripping a binding through a string representation
|
||||
*
|
||||
* After:
|
||||
* - `"KeyK"` / `"KeyK"` / `"KeyK"` / `"ArrowUp"` / `"Escape"`
|
||||
* Returns:
|
||||
* - A `+`-joined string with modifiers in canonical order followed by
|
||||
* the key. The output round-trips losslessly through
|
||||
* `parseAccelerator`.
|
||||
*
|
||||
* @example
|
||||
* formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' })
|
||||
* // => 'Mod+Shift+KeyK'
|
||||
*/
|
||||
function normalizeKeyToken(token: string): ShortcutKey {
|
||||
if (KEY_NAMES.has(token))
|
||||
return token
|
||||
|
||||
const aliased = KEY_ALIASES.get(token)
|
||||
if (aliased !== undefined)
|
||||
return aliased
|
||||
|
||||
if (SINGLE_LETTER_RE.test(token)) {
|
||||
const candidate = `Key${token.toUpperCase()}`
|
||||
if (LETTER_KEYS.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
if (SINGLE_DIGIT_RE.test(token)) {
|
||||
const candidate = `Digit${token}`
|
||||
if (DIGIT_KEYS.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
throw new Error(`Invalid accelerator: unknown key "${token}"`)
|
||||
export function formatAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_CANONICAL_TOKEN[m])
|
||||
tokens.push(acc.key)
|
||||
return tokens.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical modifier for a token, or `undefined` if the
|
||||
* token is not a modifier (i.e. probably a key).
|
||||
* Serializes a structured accelerator to Electron's accelerator string
|
||||
* format, suitable for `globalShortcut.register`.
|
||||
*
|
||||
* Use when:
|
||||
* - Calling Electron's `globalShortcut` API from the main-process
|
||||
* driver
|
||||
*
|
||||
* Returns:
|
||||
* - An Electron-format string with modifiers in canonical order
|
||||
* (`CmdOrCtrl`, `Cmd`, `Ctrl`, `Alt`, `Shift`, `Super`) followed by
|
||||
* Electron's key spelling
|
||||
*
|
||||
* @example
|
||||
* formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' })
|
||||
* // => 'CmdOrCtrl+Shift+K'
|
||||
*/
|
||||
function lookupModifierToken(token: string): ShortcutModifier | undefined {
|
||||
return MODIFIER_ALIASES.get(token.toLowerCase())
|
||||
export function formatElectronAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_ELECTRON_TOKEN[m])
|
||||
tokens.push(toElectronKey(acc.key))
|
||||
return tokens.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether `input` is a well-formed accelerator string.
|
||||
*
|
||||
* Use when:
|
||||
* - Gating user input without needing the parsed result
|
||||
*
|
||||
* Returns:
|
||||
* - `true` when `parseAccelerator` would succeed, `false` otherwise
|
||||
*/
|
||||
export function isValidAccelerator(input: string): boolean {
|
||||
try {
|
||||
parseAccelerator(input)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,26 +364,7 @@ export function parseAccelerator(input: string): ShortcutAccelerator {
|
||||
if (key === undefined)
|
||||
throw new Error(`Invalid accelerator "${input}": no key token`)
|
||||
|
||||
return { modifiers, key }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether `input` is a well-formed accelerator string.
|
||||
*
|
||||
* Use when:
|
||||
* - Gating user input without needing the parsed result
|
||||
*
|
||||
* Returns:
|
||||
* - `true` when `parseAccelerator` would succeed, `false` otherwise
|
||||
*/
|
||||
export function isValidAccelerator(input: string): boolean {
|
||||
try {
|
||||
parseAccelerator(input)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
return { key, modifiers }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,26 +375,43 @@ function canonicalModifiers(acc: ShortcutAccelerator): ShortcutModifier[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a structured accelerator back to its canonical string
|
||||
* form.
|
||||
*
|
||||
* Use when:
|
||||
* - Displaying a binding in settings UI
|
||||
* - Round-tripping a binding through a string representation
|
||||
*
|
||||
* Returns:
|
||||
* - A `+`-joined string with modifiers in canonical order followed by
|
||||
* the key. The output round-trips losslessly through
|
||||
* `parseAccelerator`.
|
||||
*
|
||||
* @example
|
||||
* formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' })
|
||||
* // => 'Mod+Shift+KeyK'
|
||||
* Returns the canonical modifier for a token, or `undefined` if the
|
||||
* token is not a modifier (i.e. probably a key).
|
||||
*/
|
||||
export function formatAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_CANONICAL_TOKEN[m])
|
||||
tokens.push(acc.key)
|
||||
return tokens.join('+')
|
||||
function lookupModifierToken(token: string): ShortcutModifier | undefined {
|
||||
return MODIFIER_ALIASES.get(token.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a raw key token to a canonical `ShortcutKey`.
|
||||
*
|
||||
* Before:
|
||||
* - `"K"` / `"k"` / `"KeyK"` / `"Up"` / `"Esc"`
|
||||
*
|
||||
* After:
|
||||
* - `"KeyK"` / `"KeyK"` / `"KeyK"` / `"ArrowUp"` / `"Escape"`
|
||||
*/
|
||||
function normalizeKeyToken(token: string): ShortcutKey {
|
||||
if (KEY_NAMES.has(token))
|
||||
return token
|
||||
|
||||
const aliased = KEY_ALIASES.get(token)
|
||||
if (aliased !== undefined)
|
||||
return aliased
|
||||
|
||||
if (SINGLE_LETTER_RE.test(token)) {
|
||||
const candidate = `Key${token.toUpperCase()}`
|
||||
if (LETTER_KEYS.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
if (SINGLE_DIGIT_RE.test(token)) {
|
||||
const candidate = `Digit${token}`
|
||||
if (DIGIT_KEYS.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
throw new Error(`Invalid accelerator: unknown key "${token}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,26 +433,3 @@ function toElectronKey(key: ShortcutKey): string {
|
||||
return ELECTRON_KEY_OVERRIDES[key]
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a structured accelerator to Electron's accelerator string
|
||||
* format, suitable for `globalShortcut.register`.
|
||||
*
|
||||
* Use when:
|
||||
* - Calling Electron's `globalShortcut` API from the main-process
|
||||
* driver
|
||||
*
|
||||
* Returns:
|
||||
* - An Electron-format string with modifiers in canonical order
|
||||
* (`CmdOrCtrl`, `Cmd`, `Ctrl`, `Alt`, `Shift`, `Super`) followed by
|
||||
* Electron's key spelling
|
||||
*
|
||||
* @example
|
||||
* formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' })
|
||||
* // => 'CmdOrCtrl+Shift+K'
|
||||
*/
|
||||
export function formatElectronAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_ELECTRON_TOKEN[m])
|
||||
tokens.push(toElectronKey(acc.key))
|
||||
return tokens.join('+')
|
||||
}
|
||||
|
||||
@@ -1,32 +1,3 @@
|
||||
/**
|
||||
* Modifier key understood by accelerator parsing and serialization.
|
||||
*
|
||||
* - `cmd-or-ctrl` — platform meta key. Resolves to Cmd on macOS,
|
||||
* Ctrl on Windows/Linux at the driver boundary.
|
||||
* - `cmd` — literal Command key.
|
||||
* - `ctrl` — literal Control key.
|
||||
* - `alt` — Alt / Option.
|
||||
* - `shift` — Shift.
|
||||
* - `super` — Super / Win / Meta key.
|
||||
*/
|
||||
export type ShortcutModifier
|
||||
= | 'cmd-or-ctrl'
|
||||
| 'cmd'
|
||||
| 'ctrl'
|
||||
| 'alt'
|
||||
| 'shift'
|
||||
| 'super'
|
||||
|
||||
/**
|
||||
* Key identifier following the W3C `KeyboardEvent.code` convention.
|
||||
* Layout-independent; refers to physical key position.
|
||||
*
|
||||
* Examples: `"KeyK"`, `"Digit1"`, `"F12"`, `"ArrowUp"`, `"Space"`,
|
||||
* `"Escape"`. The accepted set is enumerated by `KEY_NAMES` in
|
||||
* `./accelerators`.
|
||||
*/
|
||||
export type ShortcutKey = string
|
||||
|
||||
/**
|
||||
* A keyboard combination: modifiers plus a single key.
|
||||
*
|
||||
@@ -34,18 +5,10 @@ export type ShortcutKey = string
|
||||
* significant. Use `formatAccelerator` for a stable canonical string.
|
||||
*/
|
||||
export interface ShortcutAccelerator {
|
||||
modifiers: ShortcutModifier[]
|
||||
key: ShortcutKey
|
||||
modifiers: ShortcutModifier[]
|
||||
}
|
||||
|
||||
/**
|
||||
* When a shortcut is active.
|
||||
*
|
||||
* - `'global'` — fires regardless of which app or window is focused.
|
||||
* - (More will be added if needed)
|
||||
*/
|
||||
export type ShortcutScope = 'global'
|
||||
|
||||
/**
|
||||
* A registered shortcut entry.
|
||||
*
|
||||
@@ -53,12 +16,12 @@ export type ShortcutScope = 'global'
|
||||
* events; rebinding the accelerator must not change it.
|
||||
*/
|
||||
export interface ShortcutBinding {
|
||||
/** Stable identifier, e.g. `"toggle-main-window"`. */
|
||||
id: string
|
||||
/** Keyboard combination that triggers this shortcut. */
|
||||
accelerator: ShortcutAccelerator
|
||||
/** When the shortcut is active. */
|
||||
scope: ShortcutScope
|
||||
/** Human-readable description, surfaced in settings UI. */
|
||||
description?: string
|
||||
/** Stable identifier, e.g. `"toggle-main-window"`. */
|
||||
id: string
|
||||
/**
|
||||
* Whether the driver should also emit key-release events.
|
||||
*
|
||||
@@ -72,10 +35,47 @@ export interface ShortcutBinding {
|
||||
* @default false
|
||||
*/
|
||||
receiveKeyUps?: boolean
|
||||
/** Human-readable description, surfaced in settings UI. */
|
||||
description?: string
|
||||
/** When the shortcut is active. */
|
||||
scope: ShortcutScope
|
||||
}
|
||||
|
||||
/**
|
||||
* Key identifier following the W3C `KeyboardEvent.code` convention.
|
||||
* Layout-independent; refers to physical key position.
|
||||
*
|
||||
* Examples: `"KeyK"`, `"Digit1"`, `"F12"`, `"ArrowUp"`, `"Space"`,
|
||||
* `"Escape"`. The accepted set is enumerated by `KEY_NAMES` in
|
||||
* `./accelerators`.
|
||||
*/
|
||||
export type ShortcutKey = string
|
||||
|
||||
/**
|
||||
* Modifier key understood by accelerator parsing and serialization.
|
||||
*
|
||||
* - `cmd-or-ctrl` — platform meta key. Resolves to Cmd on macOS,
|
||||
* Ctrl on Windows/Linux at the driver boundary.
|
||||
* - `cmd` — literal Command key.
|
||||
* - `ctrl` — literal Control key.
|
||||
* - `alt` — Alt / Option.
|
||||
* - `shift` — Shift.
|
||||
* - `super` — Super / Win / Meta key.
|
||||
*/
|
||||
export type ShortcutModifier
|
||||
= | 'alt'
|
||||
| 'cmd'
|
||||
| 'cmd-or-ctrl'
|
||||
| 'ctrl'
|
||||
| 'shift'
|
||||
| 'super'
|
||||
|
||||
/**
|
||||
* When a shortcut is active.
|
||||
*
|
||||
* - `'global'` — fires regardless of which app or window is focused.
|
||||
* - (More will be added if needed)
|
||||
*/
|
||||
export type ShortcutScope = 'global'
|
||||
|
||||
/**
|
||||
* Closed set of failure reasons returned by drivers.
|
||||
*
|
||||
@@ -89,11 +89,6 @@ export const ShortcutFailureReasons = {
|
||||
* under a different id.
|
||||
*/
|
||||
Conflict: 'conflict',
|
||||
/**
|
||||
* An active binding already uses this id; callers must `unregister`
|
||||
* first to rebind.
|
||||
*/
|
||||
DuplicateId: 'duplicate-id',
|
||||
/**
|
||||
* The OS or portal refused the registration (e.g. user declined a
|
||||
* Wayland portal dialog, macOS denied Accessibility for a media-key
|
||||
@@ -102,16 +97,31 @@ export const ShortcutFailureReasons = {
|
||||
* reports `Conflict` for both.
|
||||
*/
|
||||
Denied: 'denied',
|
||||
/**
|
||||
* An active binding already uses this id; callers must `unregister`
|
||||
* first to rebind.
|
||||
*/
|
||||
DuplicateId: 'duplicate-id',
|
||||
/** The requested binding is well-formed but unsafe or not accepted by policy. */
|
||||
Invalid: 'invalid',
|
||||
/**
|
||||
* The driver cannot satisfy the request (e.g. a binding asks for
|
||||
* `receiveKeyUps: true` on a driver path that only delivers
|
||||
* presses).
|
||||
*/
|
||||
Unsupported: 'unsupported',
|
||||
/** The requested binding is well-formed but unsafe or not accepted by policy. */
|
||||
Invalid: 'invalid',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* In-memory shortcut config. Bump `version` on any breaking schema
|
||||
* change; consumers refuse newer versions rather than silently
|
||||
* dropping fields.
|
||||
*/
|
||||
export interface ShortcutConfig {
|
||||
bindings: ShortcutBinding[]
|
||||
version: 1
|
||||
}
|
||||
|
||||
export type ShortcutFailureReason = typeof ShortcutFailureReasons[keyof typeof ShortcutFailureReasons]
|
||||
|
||||
/**
|
||||
@@ -122,16 +132,6 @@ export type ShortcutFailureReason = typeof ShortcutFailureReasons[keyof typeof S
|
||||
* requested accelerator (e.g. user choice via a Wayland portal dialog).
|
||||
*/
|
||||
export type ShortcutRegistrationResult
|
||||
= { id: string }
|
||||
& ({ ok: true, actualAccelerator?: ShortcutAccelerator }
|
||||
| { ok: false, reason: ShortcutFailureReason })
|
||||
|
||||
/**
|
||||
* In-memory shortcut config. Bump `version` on any breaking schema
|
||||
* change; consumers refuse newer versions rather than silently
|
||||
* dropping fields.
|
||||
*/
|
||||
export interface ShortcutConfig {
|
||||
version: 1
|
||||
bindings: ShortcutBinding[]
|
||||
}
|
||||
= ({ actualAccelerator?: ShortcutAccelerator, ok: true }
|
||||
| { ok: false, reason: ShortcutFailureReason })
|
||||
& { id: string }
|
||||
|
||||
@@ -19,37 +19,37 @@ export const StageViewVec3Schema = strictObject({
|
||||
|
||||
/** Camera pose committed and persisted by the Godot stage. */
|
||||
export interface StageCameraPoseState {
|
||||
fovDeg: number
|
||||
pitchDeg: number
|
||||
position: StageViewVec3
|
||||
yawDeg: number
|
||||
pitchDeg: number
|
||||
fovDeg: number
|
||||
}
|
||||
|
||||
export const StageCameraPoseStateSchema = strictObject({
|
||||
fovDeg: finiteNumberSchema,
|
||||
pitchDeg: finiteNumberSchema,
|
||||
position: StageViewVec3Schema,
|
||||
yawDeg: finiteNumberSchema,
|
||||
pitchDeg: finiteNumberSchema,
|
||||
fovDeg: finiteNumberSchema,
|
||||
})
|
||||
|
||||
/** Godot-owned stage view state persisted by the sidecar. */
|
||||
export interface StageViewState {
|
||||
schemaVersion: 1
|
||||
revision: number
|
||||
updatedAt: number
|
||||
camera: StageCameraPoseState
|
||||
revision: number
|
||||
schemaVersion: 1
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const StageViewStateSchema = strictObject({
|
||||
schemaVersion: literal(1),
|
||||
revision: finiteIntegerSchema,
|
||||
updatedAt: finiteIntegerSchema,
|
||||
camera: StageCameraPoseStateSchema,
|
||||
revision: finiteIntegerSchema,
|
||||
schemaVersion: literal(1),
|
||||
updatedAt: finiteIntegerSchema,
|
||||
})
|
||||
|
||||
/** Camera pose mutation accepted by the Godot stage. */
|
||||
export interface StageCameraPosePatch
|
||||
extends Partial<Pick<StageCameraPoseState, 'yawDeg' | 'pitchDeg' | 'fovDeg'>> {
|
||||
extends Partial<Pick<StageCameraPoseState, 'fovDeg' | 'pitchDeg' | 'yawDeg'>> {
|
||||
position?: Partial<StageViewVec3>
|
||||
}
|
||||
|
||||
@@ -66,16 +66,12 @@ export interface StageViewRequestAckPayload {
|
||||
const StageViewVec3PatchSchema = partial(StageViewVec3Schema)
|
||||
|
||||
export const StageCameraPosePatchSchema = strictObject({
|
||||
fovDeg: optional(finiteNumberSchema),
|
||||
pitchDeg: optional(finiteNumberSchema),
|
||||
position: optional(StageViewVec3PatchSchema),
|
||||
yawDeg: optional(finiteNumberSchema),
|
||||
pitchDeg: optional(finiteNumberSchema),
|
||||
fovDeg: optional(finiteNumberSchema),
|
||||
})
|
||||
|
||||
function hasStageViewVec3PatchMutation(patch: Partial<StageViewVec3> | undefined) {
|
||||
return patch?.x !== undefined || patch?.y !== undefined || patch?.z !== undefined
|
||||
}
|
||||
|
||||
function hasStageViewPatchMutation(patch: StageViewPatch) {
|
||||
return hasStageViewVec3PatchMutation(patch.camera?.position)
|
||||
|| patch.camera?.yawDeg !== undefined
|
||||
@@ -83,6 +79,10 @@ function hasStageViewPatchMutation(patch: StageViewPatch) {
|
||||
|| patch.camera?.fovDeg !== undefined
|
||||
}
|
||||
|
||||
function hasStageViewVec3PatchMutation(patch: Partial<StageViewVec3> | undefined) {
|
||||
return patch?.x !== undefined || patch?.y !== undefined || patch?.z !== undefined
|
||||
}
|
||||
|
||||
export const StageViewPatchSchema = pipe(
|
||||
strictObject({
|
||||
camera: optional(StageCameraPosePatchSchema),
|
||||
@@ -90,6 +90,21 @@ export const StageViewPatchSchema = pipe(
|
||||
check(hasStageViewPatchMutation, 'View patch must include at least one field.'),
|
||||
)
|
||||
|
||||
/** Runtime-only avatar bounds emitted with view snapshots for UI range decisions. */
|
||||
export interface StageAvatarBoundsPayload {
|
||||
center: StageViewVec3
|
||||
maxDimension: number
|
||||
size: StageViewVec3
|
||||
}
|
||||
|
||||
/** Reason attached to a Godot view-state snapshot event. */
|
||||
export type StageViewSnapshotReason
|
||||
= | 'loaded'
|
||||
| 'local-input'
|
||||
| 'remote-patch'
|
||||
| 'request'
|
||||
| 'shutdown-flush'
|
||||
|
||||
/** Parses a host-origin Godot view-state patch. */
|
||||
export function parseStageViewPatchPayload(payload: unknown): StageViewPatch {
|
||||
const result = safeParse(StageViewPatchSchema, payload)
|
||||
@@ -99,57 +114,28 @@ export function parseStageViewPatchPayload(payload: unknown): StageViewPatch {
|
||||
return result.output
|
||||
}
|
||||
|
||||
/** Reason attached to a Godot view-state snapshot event. */
|
||||
export type StageViewSnapshotReason
|
||||
= | 'loaded'
|
||||
| 'remote-patch'
|
||||
| 'local-input'
|
||||
| 'request'
|
||||
| 'shutdown-flush'
|
||||
|
||||
/** Runtime-only avatar bounds emitted with view snapshots for UI range decisions. */
|
||||
export interface StageAvatarBoundsPayload {
|
||||
center: StageViewVec3
|
||||
size: StageViewVec3
|
||||
maxDimension: number
|
||||
}
|
||||
|
||||
export const StageAvatarBoundsPayloadSchema = strictObject({
|
||||
center: StageViewVec3Schema,
|
||||
size: StageViewVec3Schema,
|
||||
maxDimension: finiteNumberSchema,
|
||||
size: StageViewVec3Schema,
|
||||
})
|
||||
|
||||
/** Snapshot emitted by Godot after load, request, local input, or remote mutation. */
|
||||
export interface StageViewSnapshotPayload {
|
||||
state: StageViewState
|
||||
reason: StageViewSnapshotReason
|
||||
/** Runtime-only avatar bounds. This is not persisted Godot view state. */
|
||||
avatarBounds?: StageAvatarBoundsPayload
|
||||
reason: StageViewSnapshotReason
|
||||
requestId?: string
|
||||
state: StageViewState
|
||||
}
|
||||
|
||||
export const StageViewSnapshotPayloadSchema = strictObject({
|
||||
state: StageViewStateSchema,
|
||||
reason: picklist(['loaded', 'remote-patch', 'local-input', 'request', 'shutdown-flush']),
|
||||
avatarBounds: optional(StageAvatarBoundsPayloadSchema),
|
||||
reason: picklist(['loaded', 'remote-patch', 'local-input', 'request', 'shutdown-flush']),
|
||||
requestId: requestIdSchema,
|
||||
state: StageViewStateSchema,
|
||||
})
|
||||
|
||||
/** Parses a Godot-emitted view-state snapshot. */
|
||||
export function parseStageViewSnapshotPayload(payload: unknown): StageViewSnapshotPayload {
|
||||
const result = safeParse(StageViewSnapshotPayloadSchema, payload)
|
||||
if (!result.success)
|
||||
throw new Error('Invalid Godot stage view-state snapshot payload.')
|
||||
|
||||
return {
|
||||
state: result.output.state,
|
||||
reason: result.output.reason,
|
||||
...(result.output.avatarBounds ? { avatarBounds: result.output.avatarBounds } : {}),
|
||||
...(result.output.requestId != null ? { requestId: result.output.requestId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable machine-readable Godot view-state error code. */
|
||||
export type StageViewErrorCode
|
||||
= | 'invalid-payload'
|
||||
@@ -165,6 +151,20 @@ export interface StageViewErrorPayload {
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/** Parses a Godot-emitted view-state snapshot. */
|
||||
export function parseStageViewSnapshotPayload(payload: unknown): StageViewSnapshotPayload {
|
||||
const result = safeParse(StageViewSnapshotPayloadSchema, payload)
|
||||
if (!result.success)
|
||||
throw new Error('Invalid Godot stage view-state snapshot payload.')
|
||||
|
||||
return {
|
||||
reason: result.output.reason,
|
||||
state: result.output.state,
|
||||
...(result.output.avatarBounds ? { avatarBounds: result.output.avatarBounds } : {}),
|
||||
...(result.output.requestId != null ? { requestId: result.output.requestId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const StageViewErrorPayloadSchema = strictObject({
|
||||
code: picklist(['invalid-payload', 'invalid-state-file', 'persistence-failed', 'storage-root-missing', 'view-state-unavailable']),
|
||||
message: string(),
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
export const IOSubsystems = {
|
||||
VAD: 'vad',
|
||||
ASR: 'asr',
|
||||
LLM: 'llm',
|
||||
Playback: 'playback',
|
||||
StreamingControl: 'streaming-control',
|
||||
TTS: 'tts',
|
||||
Playback: 'playback',
|
||||
VAD: 'vad',
|
||||
} as const
|
||||
export type IOSubsystem = (typeof IOSubsystems)[keyof typeof IOSubsystems]
|
||||
|
||||
export const IOSpanNames = {
|
||||
AudioPlayback: 'Audio playback',
|
||||
InteractionTurn: 'Interaction turn',
|
||||
SpeechTurn: 'Speech turn',
|
||||
VoiceActivityDetection: 'Voice activity detection',
|
||||
SpeechRecognition: 'Speech recognition',
|
||||
LLMInference: 'LLM inference',
|
||||
SpeechRecognition: 'Speech recognition',
|
||||
SpeechTurn: 'Speech turn',
|
||||
StreamingControlDispatch: 'Streaming control dispatch',
|
||||
TTSSynthesis: 'TTS synthesis',
|
||||
AudioPlayback: 'Audio playback',
|
||||
VoiceActivityDetection: 'Voice activity detection',
|
||||
} as const
|
||||
|
||||
const customPrefix = 'ai.moeru.airi.io'
|
||||
|
||||
export const IOAttributes = {
|
||||
GenAIRequestModel: 'gen_ai.request.model',
|
||||
GenAIProviderName: 'gen_ai.provider.name',
|
||||
|
||||
// Non-standard
|
||||
Subsystem: `${customPrefix}.subsystem`,
|
||||
TooltipKeys: `${customPrefix}.tooltip.keys`,
|
||||
LLM_TTFT: `${customPrefix}.llm.time_to_first_token`,
|
||||
VADAudioDurationMs: `${customPrefix}.vad.audio_duration_ms`,
|
||||
VADAborted: `${customPrefix}.vad.aborted`,
|
||||
ASRText: `${customPrefix}.asr.text`,
|
||||
ASRAbort: `${customPrefix}.asr.abort`,
|
||||
ASRText: `${customPrefix}.asr.text`,
|
||||
|
||||
GenAIProviderName: 'gen_ai.provider.name',
|
||||
GenAIRequestModel: 'gen_ai.request.model',
|
||||
LLM_TTFT: `${customPrefix}.llm.time_to_first_token`,
|
||||
LLMInputMessageCount: `${customPrefix}.llm.input_message_count`,
|
||||
LLMInputMessageRoles: `${customPrefix}.llm.input_message_roles`,
|
||||
LLMInputUserMessageCount: `${customPrefix}.llm.input_user_message_count`,
|
||||
@@ -45,25 +40,30 @@ export const IOAttributes = {
|
||||
StreamingControlParameter: `${customPrefix}.streaming_control.parameter`,
|
||||
StreamingControlParsed: `${customPrefix}.streaming_control.parsed`,
|
||||
StreamingControlParserName: `${customPrefix}.streaming_control.parser_name`,
|
||||
StreamingControlReason: `${customPrefix}.streaming_control.reason`,
|
||||
StreamingControlRawToken: `${customPrefix}.streaming_control.raw_token`,
|
||||
StreamingControlReason: `${customPrefix}.streaming_control.reason`,
|
||||
StreamingControlTokenLength: `${customPrefix}.streaming_control.token_length`,
|
||||
StreamingControlTokenType: `${customPrefix}.streaming_control.token_type`,
|
||||
StreamingControlTurnId: `${customPrefix}.streaming_control.turn_id`,
|
||||
TTSSegmentId: `${customPrefix}.tts.segment_id`,
|
||||
TTSText: `${customPrefix}.tts.text`,
|
||||
TTSChunkReason: `${customPrefix}.tts.chunk_reason`,
|
||||
// Non-standard
|
||||
Subsystem: `${customPrefix}.subsystem`,
|
||||
TooltipKeys: `${customPrefix}.tooltip.keys`,
|
||||
TTSAudioDurationMs: `${customPrefix}.tts.audio_duration_ms`,
|
||||
TTSCanceled: `${customPrefix}.tts.canceled`,
|
||||
TTSChunkReason: `${customPrefix}.tts.chunk_reason`,
|
||||
TTSInterrupted: `${customPrefix}.tts.interrupted`,
|
||||
TTSInterruptReason: `${customPrefix}.tts.interrupt_reason`,
|
||||
TTSCanceled: `${customPrefix}.tts.canceled`,
|
||||
TTSSegmentId: `${customPrefix}.tts.segment_id`,
|
||||
TTSText: `${customPrefix}.tts.text`,
|
||||
TurnId: `${customPrefix}.turn_id`,
|
||||
VADAborted: `${customPrefix}.vad.aborted`,
|
||||
VADAudioDurationMs: `${customPrefix}.vad.audio_duration_ms`,
|
||||
} as const
|
||||
|
||||
export const IOEvents = {
|
||||
ASRSentenceEnd: `${customPrefix}.asr.sentence_end`,
|
||||
// Non-standard
|
||||
LLMFirstToken: `${customPrefix}.llm.first_token`,
|
||||
ASRSentenceEnd: `${customPrefix}.asr.sentence_end`,
|
||||
StreamingControlHandlerEnd: `${customPrefix}.streaming_control.handler_end`,
|
||||
StreamingControlHandlerError: `${customPrefix}.streaming_control.handler_error`,
|
||||
StreamingControlHandlerStart: `${customPrefix}.streaming_control.handler_start`,
|
||||
@@ -72,38 +72,38 @@ export const IOEvents = {
|
||||
StreamingControlSignalHandlerError: `${customPrefix}.streaming_control.signal_handler_error`,
|
||||
} as const
|
||||
|
||||
export interface IOSpan {
|
||||
endTs?: number
|
||||
/** OTel events attached to the span. */
|
||||
events?: IOSpanEvent[]
|
||||
id: string
|
||||
meta: Record<string, any>
|
||||
name: string
|
||||
|
||||
parentSpanId?: string
|
||||
startTs: number
|
||||
subsystem: IOSubsystem
|
||||
traceId: string
|
||||
ttsCorrelationId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event captured inside an IO tracing span.
|
||||
*/
|
||||
export interface IOSpanEvent {
|
||||
/** Event attributes normalized for the devtools UI. */
|
||||
meta: Record<string, unknown>
|
||||
/** OTel event name. */
|
||||
name: string
|
||||
/** Event timestamp in milliseconds. */
|
||||
timeTs: number
|
||||
/** Event attributes normalized for the devtools UI. */
|
||||
meta: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface IOSpan {
|
||||
id: string
|
||||
traceId: string
|
||||
parentSpanId?: string
|
||||
startTs: number
|
||||
endTs?: number
|
||||
|
||||
ttsCorrelationId?: string
|
||||
subsystem: IOSubsystem
|
||||
name: string
|
||||
meta: Record<string, any>
|
||||
/** OTel events attached to the span. */
|
||||
events?: IOSpanEvent[]
|
||||
}
|
||||
|
||||
export interface IOTurn {
|
||||
id: string
|
||||
startTs: number
|
||||
endTs?: number
|
||||
id: string
|
||||
inputText?: string
|
||||
outputText?: string
|
||||
spans: IOSpan[]
|
||||
startTs: number
|
||||
}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
export interface TraceEvent {
|
||||
tracerId: string
|
||||
name: string
|
||||
ts: number
|
||||
duration?: number
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
export type TraceSubscriber = (event: TraceEvent) => void
|
||||
|
||||
export interface PerfTracer {
|
||||
acquire: (token?: string) => () => void
|
||||
emit: (event: TraceEvent) => void
|
||||
forceDisable: () => void
|
||||
isEnabled: () => boolean
|
||||
acquire: (token?: string) => () => void
|
||||
mark: (tracerId: string, name: string, meta?: Record<string, any>) => void
|
||||
release: (token?: string) => void
|
||||
subscribe: (subscriber: TraceSubscriber) => () => void
|
||||
subscribeSafe: (
|
||||
subscriber: TraceSubscriber,
|
||||
options?: { label?: string, onError?: (error: unknown, event: TraceEvent) => void },
|
||||
) => () => void
|
||||
emit: (event: TraceEvent) => void
|
||||
mark: (tracerId: string, name: string, meta?: Record<string, any>) => void
|
||||
withMeasure: <T>(
|
||||
tracerId: string,
|
||||
name: string,
|
||||
@@ -28,6 +18,16 @@ export interface PerfTracer {
|
||||
) => Promise<T>
|
||||
}
|
||||
|
||||
export interface TraceEvent {
|
||||
duration?: number
|
||||
meta?: Record<string, any>
|
||||
name: string
|
||||
tracerId: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
export type TraceSubscriber = (event: TraceEvent) => void
|
||||
|
||||
export function createPerfTracer(): PerfTracer {
|
||||
let enabled = false
|
||||
const leases = new Map<string, number>()
|
||||
@@ -102,10 +102,10 @@ export function createPerfTracer(): PerfTracer {
|
||||
|
||||
function mark(tracerId: string, name: string, meta?: Record<string, any>) {
|
||||
push({
|
||||
tracerId,
|
||||
name,
|
||||
ts: performance.now(),
|
||||
meta,
|
||||
name,
|
||||
tracerId,
|
||||
ts: performance.now(),
|
||||
}, false)
|
||||
}
|
||||
|
||||
@@ -126,26 +126,26 @@ export function createPerfTracer(): PerfTracer {
|
||||
finally {
|
||||
const disabledAtEnd = !enabled
|
||||
push({
|
||||
tracerId,
|
||||
name,
|
||||
ts: start,
|
||||
duration: performance.now() - start,
|
||||
meta: disabledAtEnd
|
||||
? { ...meta, tracerDisabledDuringMeasure: true }
|
||||
: meta,
|
||||
name,
|
||||
tracerId,
|
||||
ts: start,
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
acquire,
|
||||
emit,
|
||||
forceDisable,
|
||||
isEnabled,
|
||||
acquire,
|
||||
mark,
|
||||
release,
|
||||
subscribe,
|
||||
subscribeSafe,
|
||||
emit,
|
||||
mark,
|
||||
withMeasure,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,14 +26,14 @@ export const ServerChannelQrUrlSchema = pipe(
|
||||
)
|
||||
|
||||
export const ServerChannelQrPayloadSchema = object({
|
||||
type: literal(SERVER_CHANNEL_QR_PAYLOAD_TYPE),
|
||||
version: literal(SERVER_CHANNEL_QR_PAYLOAD_VERSION),
|
||||
urls: pipe(array(ServerChannelQrUrlSchema), minLength(1)),
|
||||
authToken: string(),
|
||||
type: literal(SERVER_CHANNEL_QR_PAYLOAD_TYPE),
|
||||
urls: pipe(array(ServerChannelQrUrlSchema), minLength(1)),
|
||||
version: literal(SERVER_CHANNEL_QR_PAYLOAD_VERSION),
|
||||
})
|
||||
|
||||
export type ServerChannelQrPayloadInput = InferInput<typeof ServerChannelQrPayloadSchema>
|
||||
export type ServerChannelQrPayload = InferOutput<typeof ServerChannelQrPayloadSchema>
|
||||
export type ServerChannelQrPayloadInput = InferInput<typeof ServerChannelQrPayloadSchema>
|
||||
|
||||
export function createServerChannelQrPayload(payload: ServerChannelQrPayloadInput) {
|
||||
return parse(ServerChannelQrPayloadSchema, payload)
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* Nanosecond timestamps use strings because structured clone cannot preserve the source integer format.
|
||||
*/
|
||||
export interface SerializedIOSpan {
|
||||
traceId: string
|
||||
spanId: string
|
||||
parentSpanId: string
|
||||
name: string
|
||||
kind: number
|
||||
startTimeNano: string
|
||||
endTimeNano: string
|
||||
attributes: Record<string, unknown>
|
||||
events: Array<{ name: string, timeNano: string, attributes: Record<string, unknown> }>
|
||||
status: { code: number, message: string }
|
||||
ended: boolean
|
||||
endTimeNano: string
|
||||
events: Array<{ attributes: Record<string, unknown>, name: string, timeNano: string }>
|
||||
kind: number
|
||||
name: string
|
||||
parentSpanId: string
|
||||
spanId: string
|
||||
startTimeNano: string
|
||||
status: { code: number, message: string }
|
||||
traceId: string
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/** Lifecycle phase observed for one Pinia action invocation. */
|
||||
export type PiniaActionEventStatus = 'started' | 'completed' | 'failed'
|
||||
export type PiniaActionEventStatus = 'completed' | 'failed' | 'started'
|
||||
|
||||
/** Broadcast channel used by Pinia action tracing producers and consumers. */
|
||||
export const piniaActionTracingChannelName = 'airi-pinia-action-tracing'
|
||||
|
||||
/** Test-safe metadata emitted for one Pinia action lifecycle transition. */
|
||||
export interface PiniaActionEvent {
|
||||
/** Correlates lifecycle events from the same action invocation. */
|
||||
invocationId: string
|
||||
/** Name passed to `defineStore`. */
|
||||
storeId: string
|
||||
/** Action property name reported by Pinia. */
|
||||
actionName: string
|
||||
status: PiniaActionEventStatus
|
||||
timestamp: number
|
||||
/** Renderer URL that invoked the action, when available. */
|
||||
sourceUrl?: string
|
||||
/** Safe error text included only for failed actions. */
|
||||
errorMessage?: string
|
||||
/** Correlates lifecycle events from the same action invocation. */
|
||||
invocationId: string
|
||||
/** Renderer URL that invoked the action, when available. */
|
||||
sourceUrl?: string
|
||||
status: PiniaActionEventStatus
|
||||
/** Name passed to `defineStore`. */
|
||||
storeId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ vi.mock('gpuu/webgpu', () => ({
|
||||
const mockedCheck = vi.mocked(gpuuCheck)
|
||||
|
||||
interface MockAdapterInfo {
|
||||
vendor?: string
|
||||
architecture?: string
|
||||
device?: string
|
||||
description?: string
|
||||
device?: string
|
||||
vendor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,10 +30,10 @@ interface MockAdapterInfo {
|
||||
* `info` optionally populates adapterInfo.
|
||||
*/
|
||||
function makeMockAdapter(options: {
|
||||
maxBufferSize?: number
|
||||
info?: MockAdapterInfo
|
||||
maxBufferSize?: number
|
||||
}): any {
|
||||
const adapter: { limits: { maxBufferSize: number }, info?: MockAdapterInfo } = {
|
||||
const adapter: { info?: MockAdapterInfo, limits: { maxBufferSize: number } } = {
|
||||
limits: { maxBufferSize: options.maxBufferSize ?? 0 },
|
||||
}
|
||||
if (options.info)
|
||||
@@ -55,11 +55,11 @@ describe('detectWebGPU', () => {
|
||||
|
||||
it('should derive VRAM from the maxBufferSize * 4 heuristic', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 256 * 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 256 * 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
@@ -72,11 +72,11 @@ describe('detectWebGPU', () => {
|
||||
|
||||
it('should report "none" when the adapter has no maxBufferSize', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 0 }),
|
||||
fp16Supported: false,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 0 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
@@ -87,10 +87,10 @@ describe('detectWebGPU', () => {
|
||||
|
||||
it('should report "none" when WebGPU is unsupported', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: false,
|
||||
fp16Supported: false,
|
||||
isNode: false,
|
||||
reason: 'not available',
|
||||
supported: false,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
@@ -103,60 +103,60 @@ describe('detectWebGPU', () => {
|
||||
|
||||
it('should extract adapter.info when available', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({
|
||||
info: {
|
||||
architecture: 'apple-m1',
|
||||
description: 'Apple GPU',
|
||||
device: 'Apple M1',
|
||||
vendor: 'apple',
|
||||
},
|
||||
maxBufferSize: 1024 * 1024 * 1024,
|
||||
}),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({
|
||||
maxBufferSize: 1024 * 1024 * 1024,
|
||||
info: {
|
||||
vendor: 'apple',
|
||||
architecture: 'apple-m1',
|
||||
device: 'Apple M1',
|
||||
description: 'Apple GPU',
|
||||
},
|
||||
}),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
|
||||
expect(result.adapterInfo).toEqual({
|
||||
vendor: 'apple',
|
||||
architecture: 'apple-m1',
|
||||
device: 'Apple M1',
|
||||
description: 'Apple GPU',
|
||||
device: 'Apple M1',
|
||||
vendor: 'apple',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle adapter.info with missing fields gracefully', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({
|
||||
info: { vendor: 'nvidia' }, // only vendor set
|
||||
maxBufferSize: 1024 * 1024 * 1024,
|
||||
}),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({
|
||||
maxBufferSize: 1024 * 1024 * 1024,
|
||||
info: { vendor: 'nvidia' }, // only vendor set
|
||||
}),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
|
||||
expect(result.adapterInfo).toEqual({
|
||||
vendor: 'nvidia',
|
||||
architecture: '',
|
||||
device: '',
|
||||
description: '',
|
||||
device: '',
|
||||
vendor: 'nvidia',
|
||||
})
|
||||
})
|
||||
|
||||
it('should set adapterInfo to null when adapter.info is not exposed', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 512 * 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 512 * 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
@@ -166,10 +166,10 @@ describe('detectWebGPU', () => {
|
||||
|
||||
it('should fall back to requestAdapterInfo() when adapter.info is absent', async () => {
|
||||
const legacyInfo: MockAdapterInfo = {
|
||||
vendor: 'intel',
|
||||
architecture: 'xe',
|
||||
device: 'Iris Xe',
|
||||
description: 'Intel Xe Graphics',
|
||||
device: 'Iris Xe',
|
||||
vendor: 'intel',
|
||||
}
|
||||
|
||||
const legacyAdapter: any = {
|
||||
@@ -178,30 +178,30 @@ describe('detectWebGPU', () => {
|
||||
}
|
||||
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: legacyAdapter,
|
||||
fp16Supported: false,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: legacyAdapter,
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const result = await detectWebGPU()
|
||||
|
||||
expect(result.adapterInfo).toEqual({
|
||||
vendor: 'intel',
|
||||
architecture: 'xe',
|
||||
device: 'Iris Xe',
|
||||
description: 'Intel Xe Graphics',
|
||||
device: 'Iris Xe',
|
||||
vendor: 'intel',
|
||||
})
|
||||
})
|
||||
|
||||
it('should cache the detection result across calls', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const first = await detectWebGPU()
|
||||
@@ -213,11 +213,11 @@ describe('detectWebGPU', () => {
|
||||
|
||||
it('should deduplicate concurrent calls', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
const [a, b] = await Promise.all([detectWebGPU(), detectWebGPU()])
|
||||
@@ -253,11 +253,11 @@ describe('vRAM override', () => {
|
||||
|
||||
it('should apply the override when detection runs', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
setEstimatedVRAMOverride(8 * 1024 * 1024 * 1024) // 8 GB
|
||||
@@ -270,11 +270,11 @@ describe('vRAM override', () => {
|
||||
|
||||
it('should update cached result in-place when override is set after detection', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
await detectWebGPU()
|
||||
@@ -288,11 +288,11 @@ describe('vRAM override', () => {
|
||||
|
||||
it('should revert to heuristic when override is cleared with null', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
await detectWebGPU()
|
||||
@@ -319,11 +319,11 @@ describe('vRAM override', () => {
|
||||
|
||||
it('should accept zero as a no-op override (reverts to heuristic)', async () => {
|
||||
mockedCheck.mockResolvedValue({
|
||||
supported: true,
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
fp16Supported: true,
|
||||
isNode: false,
|
||||
reason: '',
|
||||
adapter: makeMockAdapter({ maxBufferSize: 128 * 1024 * 1024 }),
|
||||
supported: true,
|
||||
})
|
||||
|
||||
setEstimatedVRAMOverride(0)
|
||||
|
||||
@@ -19,21 +19,13 @@
|
||||
|
||||
import { check as gpuuCheck, isWebGPUSupported as gpuuIsSupported } from 'gpuu/webgpu'
|
||||
|
||||
// Minimal structural subset of the WebGPU types we interact with.
|
||||
// Avoids depending on `@webgpu/types` (which is shipped transitively via
|
||||
// transformers.js but not declared by this package).
|
||||
interface GPUAdapterInfoLike {
|
||||
vendor?: string
|
||||
architecture?: string
|
||||
device?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface GPUAdapterLike {
|
||||
limits?: { maxBufferSize?: number }
|
||||
info?: GPUAdapterInfoLike
|
||||
requestAdapterInfo?: () => Promise<GPUAdapterInfoLike>
|
||||
}
|
||||
/**
|
||||
* Source of the VRAM estimate, reported for observability.
|
||||
* - `override`: user-provided value via `setEstimatedVRAMOverride()`
|
||||
* - `max-buffer-heuristic`: derived from `adapter.limits.maxBufferSize * 4`
|
||||
* - `none`: no estimate available (WebGPU unsupported or adapter query failed)
|
||||
*/
|
||||
export type VRAMSource = 'max-buffer-heuristic' | 'none' | 'override'
|
||||
|
||||
/**
|
||||
* Subset of `GPUAdapterInfo` that we surface to consumers. Values come
|
||||
@@ -41,107 +33,59 @@ interface GPUAdapterLike {
|
||||
* opaque strings; vendor/architecture naming is not standardized.
|
||||
*/
|
||||
export interface WebGPUAdapterInfo {
|
||||
/** Vendor name, e.g. "nvidia", "apple", "intel" */
|
||||
vendor: string
|
||||
/** Architecture name, e.g. "ada-lovelace", "apple-m1" */
|
||||
architecture: string
|
||||
/** Device description, e.g. "NVIDIA GeForce RTX 4090" */
|
||||
device: string
|
||||
/** Free-form description string from the driver */
|
||||
description: string
|
||||
/** Device description, e.g. "NVIDIA GeForce RTX 4090" */
|
||||
device: string
|
||||
/** Vendor name, e.g. "nvidia", "apple", "intel" */
|
||||
vendor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of the VRAM estimate, reported for observability.
|
||||
* - `override`: user-provided value via `setEstimatedVRAMOverride()`
|
||||
* - `max-buffer-heuristic`: derived from `adapter.limits.maxBufferSize * 4`
|
||||
* - `none`: no estimate available (WebGPU unsupported or adapter query failed)
|
||||
*/
|
||||
export type VRAMSource = 'override' | 'max-buffer-heuristic' | 'none'
|
||||
|
||||
export interface WebGPUCapabilities {
|
||||
/** Whether WebGPU is available in this environment */
|
||||
supported: boolean
|
||||
/** Whether fp16 shader operations are supported */
|
||||
fp16Supported: boolean
|
||||
/** Adapter-reported vendor/architecture/device info, when available */
|
||||
adapterInfo: null | WebGPUAdapterInfo
|
||||
/** Estimated VRAM in bytes (0 when unavailable) */
|
||||
estimatedVRAM: number
|
||||
/** Provenance of the VRAM estimate */
|
||||
estimatedVRAMSource: VRAMSource
|
||||
/** Adapter-reported vendor/architecture/device info, when available */
|
||||
adapterInfo: WebGPUAdapterInfo | null
|
||||
/** Whether fp16 shader operations are supported */
|
||||
fp16Supported: boolean
|
||||
/** Raw reason string from gpuu when unsupported */
|
||||
reason: string
|
||||
/** Whether WebGPU is available in this environment */
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
let cachedResult: WebGPUCapabilities | null = null
|
||||
let pendingDetection: Promise<WebGPUCapabilities> | null = null
|
||||
// Minimal structural subset of the WebGPU types we interact with.
|
||||
// Avoids depending on `@webgpu/types` (which is shipped transitively via
|
||||
// transformers.js but not declared by this package).
|
||||
interface GPUAdapterInfoLike {
|
||||
architecture?: string
|
||||
description?: string
|
||||
device?: string
|
||||
vendor?: string
|
||||
}
|
||||
|
||||
interface GPUAdapterLike {
|
||||
info?: GPUAdapterInfoLike
|
||||
limits?: { maxBufferSize?: number }
|
||||
requestAdapterInfo?: () => Promise<GPUAdapterInfoLike>
|
||||
}
|
||||
|
||||
let cachedResult: null | WebGPUCapabilities = null
|
||||
let pendingDetection: null | Promise<WebGPUCapabilities> = null
|
||||
|
||||
// NOTICE: User override for VRAM estimation. When set, this value takes
|
||||
// priority over all heuristics. Useful for users with known hardware where
|
||||
// the heuristic is inaccurate (e.g. discrete GPUs with small maxBufferSize).
|
||||
let vramOverride: number | null = null
|
||||
let vramOverride: null | number = null
|
||||
|
||||
// Cached heuristic value so we can restore it when the override is cleared.
|
||||
// Computed during detectWebGPU() and persisted for the lifetime of the cache.
|
||||
let cachedHeuristicVRAM = 0
|
||||
|
||||
/**
|
||||
* Best-effort extraction of `GPUAdapterInfo` from a `GPUAdapter`. Tries
|
||||
* the modern synchronous `adapter.info` first, then falls back to the
|
||||
* legacy `requestAdapterInfo()` promise API. Returns null if neither works.
|
||||
*
|
||||
* References:
|
||||
* - https://www.w3.org/TR/webgpu/#gpu-adapterinfo
|
||||
*/
|
||||
async function extractAdapterInfo(adapter: GPUAdapterLike): Promise<WebGPUAdapterInfo | null> {
|
||||
try {
|
||||
// Modern API: synchronous `info` property (Chrome 114+, Safari 17.4+)
|
||||
const info = adapter.info
|
||||
if (info) {
|
||||
return {
|
||||
vendor: info.vendor ?? '',
|
||||
architecture: info.architecture ?? '',
|
||||
device: info.device ?? '',
|
||||
description: info.description ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy API: requestAdapterInfo() returns a Promise
|
||||
const legacy = adapter.requestAdapterInfo
|
||||
if (typeof legacy === 'function') {
|
||||
const legacyInfo = await legacy.call(adapter)
|
||||
return {
|
||||
vendor: legacyInfo.vendor ?? '',
|
||||
architecture: legacyInfo.architecture ?? '',
|
||||
device: legacyInfo.device ?? '',
|
||||
description: legacyInfo.description ?? '',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Fall through to null — adapter info is best-effort, not required
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Compute the heuristic VRAM estimate from `maxBufferSize`. */
|
||||
function computeHeuristicVRAM(adapter: GPUAdapterLike): number {
|
||||
const maxBuffer = adapter.limits?.maxBufferSize ?? 0
|
||||
// Typical values: 256 MB on integrated GPUs, 2-4 GB on discrete.
|
||||
// Multiply by 4 as a conservative total VRAM heuristic.
|
||||
return maxBuffer > 0 ? maxBuffer * 4 : 0
|
||||
}
|
||||
|
||||
/** Decide the VRAM estimate based on override > heuristic > none. */
|
||||
function resolveVRAM(heuristic: number): { bytes: number, source: VRAMSource } {
|
||||
if (vramOverride !== null && vramOverride > 0)
|
||||
return { bytes: vramOverride, source: 'override' }
|
||||
if (heuristic > 0)
|
||||
return { bytes: heuristic, source: 'max-buffer-heuristic' }
|
||||
return { bytes: 0, source: 'none' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect WebGPU capabilities. The result is cached as a singleton
|
||||
* after the first successful call -- safe to call repeatedly.
|
||||
@@ -158,7 +102,7 @@ export async function detectWebGPU(): Promise<WebGPUCapabilities> {
|
||||
try {
|
||||
const result = await gpuuCheck()
|
||||
|
||||
let adapterInfo: WebGPUAdapterInfo | null = null
|
||||
let adapterInfo: null | WebGPUAdapterInfo = null
|
||||
let heuristic = 0
|
||||
if (result.supported && result.adapter) {
|
||||
heuristic = computeHeuristicVRAM(result.adapter)
|
||||
@@ -169,23 +113,23 @@ export async function detectWebGPU(): Promise<WebGPUCapabilities> {
|
||||
const vram = resolveVRAM(heuristic)
|
||||
|
||||
cachedResult = {
|
||||
supported: result.supported,
|
||||
fp16Supported: result.fp16Supported ?? false,
|
||||
adapterInfo,
|
||||
estimatedVRAM: vram.bytes,
|
||||
estimatedVRAMSource: vram.source,
|
||||
adapterInfo,
|
||||
fp16Supported: result.fp16Supported ?? false,
|
||||
reason: result.reason ?? '',
|
||||
supported: result.supported,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
cachedHeuristicVRAM = 0
|
||||
cachedResult = {
|
||||
supported: false,
|
||||
fp16Supported: false,
|
||||
adapterInfo: null,
|
||||
estimatedVRAM: 0,
|
||||
estimatedVRAMSource: 'none',
|
||||
adapterInfo: null,
|
||||
fp16Supported: false,
|
||||
reason: 'Detection threw an exception',
|
||||
supported: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,10 +144,15 @@ export async function detectWebGPU(): Promise<WebGPUCapabilities> {
|
||||
* Synchronous check -- returns the cached result or `null` if
|
||||
* `detectWebGPU()` has not been awaited yet.
|
||||
*/
|
||||
export function getCachedWebGPUCapabilities(): WebGPUCapabilities | null {
|
||||
export function getCachedWebGPUCapabilities(): null | WebGPUCapabilities {
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
/** Read the current VRAM override, or null if unset. */
|
||||
export function getEstimatedVRAMOverride(): null | number {
|
||||
return vramOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple boolean helper that matches the old `isWebGPUSupported()` API.
|
||||
* Prefer `detectWebGPU()` when you need more detail.
|
||||
@@ -213,6 +162,15 @@ export async function isWebGPUSupported(): Promise<boolean> {
|
||||
return gpuuIsSupported()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached detection result. Intended for tests only.
|
||||
*/
|
||||
export function resetWebGPUCache(): void {
|
||||
cachedResult = null
|
||||
pendingDetection = null
|
||||
cachedHeuristicVRAM = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the estimated VRAM value. Pass `null` to clear the override and
|
||||
* revert to the heuristic. The override applies to future detections, and if
|
||||
@@ -221,7 +179,7 @@ export async function isWebGPUSupported(): Promise<boolean> {
|
||||
*
|
||||
* Intended for user preference UI ("I have 8 GB VRAM") and testing.
|
||||
*/
|
||||
export function setEstimatedVRAMOverride(bytes: number | null): void {
|
||||
export function setEstimatedVRAMOverride(bytes: null | number): void {
|
||||
if (bytes !== null && (!Number.isFinite(bytes) || bytes < 0))
|
||||
throw new Error(`Invalid VRAM override: ${bytes} (expected null or non-negative finite number)`)
|
||||
|
||||
@@ -241,16 +199,58 @@ export function setEstimatedVRAMOverride(bytes: number | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current VRAM override, or null if unset. */
|
||||
export function getEstimatedVRAMOverride(): number | null {
|
||||
return vramOverride
|
||||
/** Compute the heuristic VRAM estimate from `maxBufferSize`. */
|
||||
function computeHeuristicVRAM(adapter: GPUAdapterLike): number {
|
||||
const maxBuffer = adapter.limits?.maxBufferSize ?? 0
|
||||
// Typical values: 256 MB on integrated GPUs, 2-4 GB on discrete.
|
||||
// Multiply by 4 as a conservative total VRAM heuristic.
|
||||
return maxBuffer > 0 ? maxBuffer * 4 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached detection result. Intended for tests only.
|
||||
* Best-effort extraction of `GPUAdapterInfo` from a `GPUAdapter`. Tries
|
||||
* the modern synchronous `adapter.info` first, then falls back to the
|
||||
* legacy `requestAdapterInfo()` promise API. Returns null if neither works.
|
||||
*
|
||||
* References:
|
||||
* - https://www.w3.org/TR/webgpu/#gpu-adapterinfo
|
||||
*/
|
||||
export function resetWebGPUCache(): void {
|
||||
cachedResult = null
|
||||
pendingDetection = null
|
||||
cachedHeuristicVRAM = 0
|
||||
async function extractAdapterInfo(adapter: GPUAdapterLike): Promise<null | WebGPUAdapterInfo> {
|
||||
try {
|
||||
// Modern API: synchronous `info` property (Chrome 114+, Safari 17.4+)
|
||||
const info = adapter.info
|
||||
if (info) {
|
||||
return {
|
||||
architecture: info.architecture ?? '',
|
||||
description: info.description ?? '',
|
||||
device: info.device ?? '',
|
||||
vendor: info.vendor ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy API: requestAdapterInfo() returns a Promise
|
||||
const legacy = adapter.requestAdapterInfo
|
||||
if (typeof legacy === 'function') {
|
||||
const legacyInfo = await legacy.call(adapter)
|
||||
return {
|
||||
architecture: legacyInfo.architecture ?? '',
|
||||
description: legacyInfo.description ?? '',
|
||||
device: legacyInfo.device ?? '',
|
||||
vendor: legacyInfo.vendor ?? '',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Fall through to null — adapter info is best-effort, not required
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Decide the VRAM estimate based on override > heuristic > none. */
|
||||
function resolveVRAM(heuristic: number): { bytes: number, source: VRAMSource } {
|
||||
if (vramOverride !== null && vramOverride > 0)
|
||||
return { bytes: vramOverride, source: 'override' }
|
||||
if (heuristic > 0)
|
||||
return { bytes: heuristic, source: 'max-buffer-heuristic' }
|
||||
return { bytes: 0, source: 'none' }
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { isStageTamagotchi } from './environment'
|
||||
|
||||
export interface ElectronWindow<CustomApi = unknown> {
|
||||
api: CustomApi
|
||||
electron: ElectronAPI
|
||||
platform: NodeJS.Platform
|
||||
api: CustomApi
|
||||
}
|
||||
|
||||
export function isElectronWindow<CustomApi = unknown>(window: Window): window is (Window & ElectronWindow<CustomApi>) {
|
||||
export function isElectronWindow<CustomApi = unknown>(window: Window): window is (ElectronWindow<CustomApi> & Window) {
|
||||
return isStageTamagotchi() && typeof window === 'object' && window !== null && 'electron' in window
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user