style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
+86 -86
View File
@@ -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.' },
],
},
]
+9 -9
View File
@@ -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.
*/
+123 -123
View File
@@ -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,
}
+5 -5
View File
@@ -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
}
+1 -1
View File
@@ -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
@@ -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
+5 -5
View File
@@ -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)
}
+13 -13
View File
@@ -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'
+10 -10
View File
@@ -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(),
+41 -41
View File
@@ -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
}
+22 -22
View File
@@ -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)
+9 -9
View File
@@ -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
}
+48 -48
View File
@@ -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)
+109 -109
View File
@@ -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' }
}
+2 -2
View File
@@ -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
}