feat(vishot-*,scenarios-*): new packages, and playwright high level abstraction
Co-authored-by-agent: Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
This commit is contained in:
@@ -305,6 +305,7 @@ words:
|
||||
- valibot
|
||||
- vaul
|
||||
- velin
|
||||
- vishot
|
||||
- VITE
|
||||
- vitepress
|
||||
- vllm
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"lint": "moeru-lint .",
|
||||
"lint:fix": "moeru-lint --fix .",
|
||||
"lint:swift": "pnpm -rF @proj-airi/stage-pocket run lint:swift",
|
||||
"capture:tamagotchi": "pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture --output-dir ./artifacts/manual-run",
|
||||
"capture:tamagotchi": "pnpm -F @proj-airi/vishot-runner-electron capture -- ./packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ./packages/scenarios-stage-tamagotchi-browser/artifacts/raw",
|
||||
"to-avif": "tsx docs/scripts/avif.ts",
|
||||
"sponsors:generate": "sponsorkit --output-dir docs/content/public/assets/sponsors",
|
||||
"typecheck": "pnpm -rF=\"./packages/*\" -F=\"./apps/*\" -F=\"./docs\" --parallel typecheck",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export { defineScenario } from './runtime/define-scenario'
|
||||
export type {
|
||||
CaptureOptions,
|
||||
ControlsIslandApi,
|
||||
DialogsApi,
|
||||
DrawersApi,
|
||||
ScenarioContext,
|
||||
SettingsWindowApi,
|
||||
StageTamagotchiScenario,
|
||||
StageWindowsApi,
|
||||
} from './runtime/types'
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { StageTamagotchiScenario } from './types'
|
||||
|
||||
export function defineScenario(scenario: StageTamagotchiScenario): StageTamagotchiScenario {
|
||||
return scenario
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Scenarios - Stage Tamagotchi Browser
|
||||
|
||||
Compose final browser-scene exports for stage-tamagotchi from raw Electron screenshots.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package is the tamagotchi browser composition layer. It contains:
|
||||
|
||||
- the Vite/Vue scene app
|
||||
- the scene composition components and capture roots
|
||||
- the `capture` script that exports final PNGs through `@proj-airi/vishot-runner-browser`
|
||||
|
||||
It expects raw business screenshots to exist in `artifacts/raw` before final export runs.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build the Electron app.
|
||||
2. Capture raw screenshots into this package's `artifacts/raw` directory.
|
||||
3. Run this package's `capture` script to export the composed scene roots into `artifacts/final`.
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/stage-tamagotchi build
|
||||
pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir packages/scenarios-stage-tamagotchi-browser/artifacts/raw
|
||||
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture
|
||||
```
|
||||
|
||||
Expected raw inputs:
|
||||
|
||||
- `artifacts/raw/02-chat-window.png`
|
||||
- `artifacts/raw/03-websocket-settings.png`
|
||||
|
||||
Current composed outputs:
|
||||
|
||||
- `artifacts/final/intro-chat-window.png`
|
||||
- `artifacts/final/intro-websocket-settings.png`
|
||||
|
||||
## Notes
|
||||
|
||||
- The current scene app renders `src/scenes/intro-manual-scene.vue`.
|
||||
- Final export depends on the browser scene reaching the `__SCENARIO_CAPTURE_READY__` flag after its raw images load.
|
||||
- If raw capture is missing or stale, final export will fail or render outdated assets.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stage Tamagotchi Scenes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@proj-airi/scenarios-stage-tamagotchi-browser",
|
||||
"type": "module",
|
||||
"version": "0.9.0-beta.0",
|
||||
"private": true,
|
||||
"description": "Vite/Vue scene app for stage tamagotchi browser captures",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/scenarios-stage-tamagotchi-browser"
|
||||
},
|
||||
"scripts": {
|
||||
"capture": "tsx scripts/capture.ts",
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@proj-airi/vishot-runner-browser": "workspace:^",
|
||||
"@proj-airi/vishot-runtime": "workspace:^",
|
||||
"date-fns": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/line-md": "catalog:",
|
||||
"@iconify-json/material-symbols": "catalog:",
|
||||
"@iconify-json/mdi": "catalog:",
|
||||
"@types/node": "^24.12.0",
|
||||
"@unocss/reset": "^66.6.7",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "catalog:",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { cwd } from 'node:process'
|
||||
|
||||
import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser'
|
||||
|
||||
const sceneAppRoot = path.resolve(cwd())
|
||||
const outputDir = path.resolve(sceneAppRoot, 'artifacts', 'final')
|
||||
|
||||
await captureBrowserRoots({
|
||||
sceneAppRoot,
|
||||
routePath: '/',
|
||||
outputDir,
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import IntroManualScene from './scenes/intro-manual-scene.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IntroManualScene />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 539 KiB |
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '../assets/icon.png'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="AppIcon" alt="App Icon">
|
||||
</template>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Appearance, Dock, MenuBar } from './ui'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
aspectRatio?: string | number
|
||||
dockSize?: number
|
||||
}>(), {
|
||||
aspectRatio: '16:9',
|
||||
dockSize: 1.5,
|
||||
})
|
||||
|
||||
const aspectRatio = computed(() => {
|
||||
if (!props.aspectRatio) {
|
||||
return 16 / 9
|
||||
}
|
||||
if (typeof props.aspectRatio === 'number') {
|
||||
return props.aspectRatio
|
||||
}
|
||||
|
||||
return props.aspectRatio.split(':').map(Number).reduce((a, b) => a / b)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative overflow-hidden',
|
||||
'font-macos',
|
||||
]"
|
||||
:style="{
|
||||
aspectRatio,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'relative z-999',
|
||||
'w-full h-full',
|
||||
]"
|
||||
>
|
||||
<slot name="windows" />
|
||||
</div>
|
||||
<Appearance />
|
||||
<MenuBar />
|
||||
<Dock :size="props.dockSize" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.font-macos {
|
||||
font-family: 'Inter', 'Helvetica', 'Arial', sans-serif;
|
||||
}
|
||||
</style>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { StyleValue } from 'vue'
|
||||
|
||||
import { inject, toRef } from 'vue'
|
||||
|
||||
import { injectDockSize } from './constants'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: string | string[]
|
||||
style?: StyleValue
|
||||
baseSize?: number
|
||||
running?: boolean
|
||||
notificationCount?: number
|
||||
}>(), {})
|
||||
|
||||
const dockSize = toRef(() => inject(injectDockSize, 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
...props.class ? typeof props.class === 'string' ? [props.class] : props.class : [],
|
||||
'relative',
|
||||
]"
|
||||
:style="{
|
||||
...props.style ? typeof props.style === 'object' ? props.style : {} : {},
|
||||
width: `${props.baseSize ? props.baseSize * dockSize : 32 * dockSize}px`,
|
||||
height: `${props.baseSize ? props.baseSize * dockSize : 32 * dockSize}px`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="props.running"
|
||||
:class="[
|
||||
'absolute top-1/2 right--0.8 translate-y--1/2',
|
||||
'rounded-full',
|
||||
'bg-white/75',
|
||||
]"
|
||||
:style="{
|
||||
width: `${3.2 * dockSize}px`,
|
||||
height: `${3.2 * dockSize}px`,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
v-if="props.notificationCount"
|
||||
:class="[
|
||||
'absolute top-0 right-[calc(-50%+5%)] translate-x-[-100%] translate-y-[calc(-50%+16%)]',
|
||||
'rounded-full',
|
||||
'bg-red-500',
|
||||
]"
|
||||
:style="{
|
||||
width: `${12 * dockSize}px`,
|
||||
height: `${12 * dockSize}px`,
|
||||
}"
|
||||
>
|
||||
{{ props.notificationCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'mt-2 mb-1',
|
||||
'w-[100%] h-0.25',
|
||||
'bg-white/50',
|
||||
'rounded-full',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
|
||||
import { injectDockSize } from './constants'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 1,
|
||||
})
|
||||
|
||||
provide(injectDockSize, props.size)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export const injectDockSize: InjectionKey<number> = Symbol('vishot:platforms:macos-26:dock:size')
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { default as Application } from './Application.vue'
|
||||
export { default as DockDivider } from './Divider.vue'
|
||||
export { default as DockRoot } from './DockRoot.vue'
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
focus?: boolean
|
||||
frame?: boolean
|
||||
transparent?: boolean
|
||||
titleBarStyle?: 'default' | 'hidden' | 'hiddenInset' | 'customButtonsOnHover'
|
||||
hasShadow?: boolean
|
||||
alignTo?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'center'
|
||||
}>(), {
|
||||
focus: true,
|
||||
frame: true,
|
||||
transparent: false,
|
||||
titleBarStyle: 'default',
|
||||
hasShadow: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'absolute',
|
||||
'flex flex-col',
|
||||
'rounded-2xl overflow-hidden',
|
||||
props.hasShadow ? 'shadow-xl' : '',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="!!props.frame"
|
||||
:class="[
|
||||
'flex gap-2 px-2.5 py-2',
|
||||
'bg-white',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex gap-2',
|
||||
]"
|
||||
>
|
||||
<div :class="['bg-[#FF5C5F] dark:bg-[#FF5C5F]', 'size-3.5', 'rounded-full']" />
|
||||
<div :class="['bg-[#FAC800] dark:bg-[#FAC800]', 'size-3.5', 'rounded-full']" />
|
||||
<div :class="['bg-[#34C759] dark:bg-[#34C759]', 'size-3.5', 'rounded-full']" />
|
||||
</div>
|
||||
<div v-if="props.title">
|
||||
<span>{{ props.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!!props.frame"
|
||||
:class="[
|
||||
'bg-[#E6E6E6] h-0.25 w-full',
|
||||
]"
|
||||
/>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default as WindowRoot } from './WindowRoot.vue'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from './liquid-glass'
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { splitImageDataToParts } from '../helpers/split-imagedata-to-parts'
|
||||
|
||||
interface CompositePartsProps {
|
||||
imageData: ImageData
|
||||
cornerWidth: number
|
||||
pixelRatio: number
|
||||
width: number
|
||||
height: number
|
||||
result: string
|
||||
hideTop?: boolean
|
||||
hideBottom?: boolean
|
||||
hideLeft?: boolean
|
||||
hideRight?: boolean
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'CompositeParts',
|
||||
})
|
||||
|
||||
const props = defineProps<CompositePartsProps>()
|
||||
|
||||
const parts = computed(() => splitImageDataToParts({
|
||||
imageData: props.imageData,
|
||||
cornerWidth: props.cornerWidth,
|
||||
pixelRatio: props.pixelRatio,
|
||||
}))
|
||||
|
||||
const widthMinusCorner = computed(() => props.width - props.cornerWidth)
|
||||
const heightMinusCorner = computed(() => props.height - props.cornerWidth)
|
||||
|
||||
const compositePartNames = computed(() =>
|
||||
[
|
||||
!props.hideTop && 'top',
|
||||
!props.hideLeft && 'left',
|
||||
!props.hideRight && 'right',
|
||||
!props.hideBottom && 'bottom',
|
||||
!props.hideTop && !props.hideLeft && 'topLeft',
|
||||
!props.hideTop && !props.hideRight && 'topRight',
|
||||
!props.hideBottom && !props.hideLeft && 'bottomLeft',
|
||||
!props.hideBottom && !props.hideRight && 'bottomRight',
|
||||
].filter((partName): partName is string => typeof partName === 'string'),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<feImage
|
||||
:href="parts.topLeft"
|
||||
:x="0"
|
||||
:y="0"
|
||||
:width="cornerWidth"
|
||||
:height="cornerWidth"
|
||||
:result="`${result}_topLeft`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.top"
|
||||
:x="0"
|
||||
:y="0"
|
||||
:width="width"
|
||||
:height="cornerWidth"
|
||||
:result="`${result}_top`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.topRight"
|
||||
:x="widthMinusCorner"
|
||||
:y="0"
|
||||
:width="cornerWidth"
|
||||
:height="cornerWidth"
|
||||
:result="`${result}_topRight`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.left"
|
||||
:x="0"
|
||||
:y="0"
|
||||
:width="cornerWidth"
|
||||
:height="height"
|
||||
:result="`${result}_left`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.right"
|
||||
:x="widthMinusCorner"
|
||||
:y="0"
|
||||
:width="cornerWidth"
|
||||
:height="height"
|
||||
:result="`${result}_right`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.bottomLeft"
|
||||
:x="0"
|
||||
:y="heightMinusCorner"
|
||||
:width="cornerWidth"
|
||||
:height="cornerWidth"
|
||||
:result="`${result}_bottomLeft`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.bottom"
|
||||
:x="0"
|
||||
:y="heightMinusCorner"
|
||||
:width="width"
|
||||
:height="cornerWidth"
|
||||
:result="`${result}_bottom`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
<feImage
|
||||
:href="parts.bottomRight"
|
||||
:x="widthMinusCorner"
|
||||
:y="heightMinusCorner"
|
||||
:width="cornerWidth"
|
||||
:height="cornerWidth"
|
||||
:result="`${result}_bottomRight`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
|
||||
<feImage
|
||||
:href="parts.center"
|
||||
:x="0"
|
||||
:y="0"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:result="`${result}_base`"
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
|
||||
<feComposite
|
||||
v-for="(partName, index) in compositePartNames"
|
||||
:key="partName"
|
||||
operator="over"
|
||||
:in="`${result}_${partName}`"
|
||||
:in2="index === 0 ? `${result}_base` : `${result}_composite_${index}`"
|
||||
:result="index === compositePartNames.length - 1 ? result : `${result}_composite_${index}`"
|
||||
/>
|
||||
</template>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import CompositeParts from './composite-parts.vue'
|
||||
|
||||
import {
|
||||
calculateDisplacementMap,
|
||||
calculateDisplacementMapRadius,
|
||||
} from '../maps/displacement-map'
|
||||
import { calculateSpecularImage } from '../maps/specular'
|
||||
|
||||
interface FilterProps {
|
||||
id: string
|
||||
scaleRatio: number
|
||||
blur: number
|
||||
width: number
|
||||
height: number
|
||||
radius: number
|
||||
glassThickness: number
|
||||
bezelWidth: number
|
||||
refractiveIndex: number
|
||||
specularOpacity: number
|
||||
specularAngle: number
|
||||
bezelHeightFn: (x: number) => number
|
||||
pixelRatio: number
|
||||
hideTop?: boolean
|
||||
hideBottom?: boolean
|
||||
hideLeft?: boolean
|
||||
hideRight?: boolean
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'LiquidGlassFilter',
|
||||
})
|
||||
|
||||
const props = defineProps<FilterProps>()
|
||||
|
||||
const cornerWidth = computed(() => Math.max(props.radius, props.bezelWidth))
|
||||
const imageSide = computed(() => cornerWidth.value * 2 + 1)
|
||||
|
||||
const displacementRadiusMap = computed(() => calculateDisplacementMapRadius(
|
||||
props.glassThickness,
|
||||
props.bezelWidth,
|
||||
props.bezelHeightFn,
|
||||
props.refractiveIndex,
|
||||
))
|
||||
|
||||
const maximumDisplacement = computed(() =>
|
||||
Math.max(...displacementRadiusMap.value.map(Math.abs)),
|
||||
)
|
||||
|
||||
const displacementMap = computed(() => calculateDisplacementMap({
|
||||
width: imageSide.value,
|
||||
height: imageSide.value,
|
||||
radius: props.radius,
|
||||
bezelWidth: props.bezelWidth,
|
||||
precomputedDisplacementMap: displacementRadiusMap.value,
|
||||
maximumDisplacement: maximumDisplacement.value,
|
||||
pixelRatio: props.pixelRatio,
|
||||
}))
|
||||
|
||||
const specularMap = computed(() => calculateSpecularImage({
|
||||
width: imageSide.value,
|
||||
height: imageSide.value,
|
||||
radius: props.radius,
|
||||
specularAngle: props.specularAngle,
|
||||
pixelRatio: props.pixelRatio,
|
||||
}))
|
||||
|
||||
const scale = computed(() => maximumDisplacement.value * props.scaleRatio)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg color-interpolation-filters="sRGB" :style="{ display: 'none' }">
|
||||
<defs>
|
||||
<filter :id="id">
|
||||
<feGaussianBlur
|
||||
in="SourceGraphic"
|
||||
:stdDeviation="blur"
|
||||
result="blurred_source"
|
||||
/>
|
||||
|
||||
<CompositeParts
|
||||
:image-data="displacementMap"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:corner-width="cornerWidth"
|
||||
:pixel-ratio="pixelRatio"
|
||||
result="displacement_map"
|
||||
:hide-top="hideTop"
|
||||
:hide-bottom="hideBottom"
|
||||
:hide-left="hideLeft"
|
||||
:hide-right="hideRight"
|
||||
/>
|
||||
|
||||
<CompositeParts
|
||||
:image-data="specularMap"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:corner-width="cornerWidth"
|
||||
:pixel-ratio="pixelRatio"
|
||||
result="specular_map"
|
||||
:hide-top="hideTop"
|
||||
:hide-bottom="hideBottom"
|
||||
:hide-left="hideLeft"
|
||||
:hide-right="hideRight"
|
||||
/>
|
||||
|
||||
<feDisplacementMap
|
||||
in="blurred_source"
|
||||
in2="displacement_map"
|
||||
:scale="scale"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
result="displaced_source"
|
||||
/>
|
||||
|
||||
<feColorMatrix
|
||||
in="specular_map"
|
||||
type="luminanceToAlpha"
|
||||
result="specular_alpha"
|
||||
/>
|
||||
|
||||
<feComponentTransfer in="specular_alpha" result="specular_with_opacity">
|
||||
<feFuncA type="linear" :slope="specularOpacity" />
|
||||
</feComponentTransfer>
|
||||
|
||||
<feFlood flood-color="white" result="white_layer" />
|
||||
|
||||
<feComposite
|
||||
in="white_layer"
|
||||
in2="specular_with_opacity"
|
||||
operator="in"
|
||||
result="masked_specular"
|
||||
/>
|
||||
|
||||
<feComposite
|
||||
in="masked_specular"
|
||||
in2="displaced_source"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/helpers/image-data-to-url.ts}
|
||||
*/
|
||||
export function imageDataToUrl(
|
||||
imageData: ImageData,
|
||||
width?: number,
|
||||
height?: number,
|
||||
x = 0,
|
||||
y = 0,
|
||||
): string {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width ?? imageData.width
|
||||
canvas.height = height ?? imageData.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.putImageData(imageData, -x, -y)
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/* eslint-disable jsdoc/check-param-names */
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/helpers/split-imagedata-to-parts.ts}
|
||||
*/
|
||||
import { imageDataToUrl } from './image-data-to-url'
|
||||
|
||||
// Each part is a Base64-encoded PNG image
|
||||
export interface Parts {
|
||||
topLeft: string
|
||||
top: string
|
||||
topRight: string
|
||||
left: string
|
||||
center: string
|
||||
right: string
|
||||
bottomLeft: string
|
||||
bottom: string
|
||||
bottomRight: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an ImageData into 8 parts and returns them as Base64-encoded PNG images.
|
||||
* The parts can then be rendered and composited together to form a scalable image with correct corners and edges.
|
||||
*
|
||||
* @param props - The properties for splitting the image data.
|
||||
* @returns The 8 parts of the image as Base64-encoded PNG images.
|
||||
*/
|
||||
export function splitImageDataToParts(props: {
|
||||
imageData: ImageData
|
||||
cornerWidth: number
|
||||
pixelRatio: number
|
||||
}): Parts {
|
||||
const { imageData } = props
|
||||
const cornerWidth = props.cornerWidth * props.pixelRatio
|
||||
// This is to always keep width and height odd, so we can always extract a center part to stretch
|
||||
const lateralPartSize = 1 * props.pixelRatio
|
||||
|
||||
if (imageData.width !== cornerWidth * 2 + lateralPartSize) {
|
||||
throw new Error('ImageData width is too small for the given corner width')
|
||||
}
|
||||
if (imageData.height !== imageData.width) {
|
||||
throw new Error('ImageData should be square')
|
||||
}
|
||||
|
||||
const topLeft = imageDataToUrl(imageData, cornerWidth, cornerWidth, 0, 0)
|
||||
const top = imageDataToUrl(
|
||||
imageData,
|
||||
lateralPartSize,
|
||||
cornerWidth,
|
||||
cornerWidth,
|
||||
0,
|
||||
)
|
||||
const topRight = imageDataToUrl(
|
||||
imageData,
|
||||
cornerWidth,
|
||||
cornerWidth,
|
||||
cornerWidth + lateralPartSize,
|
||||
0,
|
||||
)
|
||||
const left = imageDataToUrl(
|
||||
imageData,
|
||||
cornerWidth,
|
||||
lateralPartSize,
|
||||
0,
|
||||
cornerWidth,
|
||||
)
|
||||
const center = imageDataToUrl(
|
||||
imageData,
|
||||
lateralPartSize,
|
||||
lateralPartSize,
|
||||
cornerWidth,
|
||||
cornerWidth,
|
||||
)
|
||||
const right = imageDataToUrl(
|
||||
imageData,
|
||||
cornerWidth,
|
||||
lateralPartSize,
|
||||
cornerWidth + lateralPartSize,
|
||||
cornerWidth,
|
||||
)
|
||||
const bottomLeft = imageDataToUrl(
|
||||
imageData,
|
||||
cornerWidth,
|
||||
cornerWidth,
|
||||
0,
|
||||
cornerWidth + lateralPartSize,
|
||||
)
|
||||
const bottom = imageDataToUrl(
|
||||
imageData,
|
||||
lateralPartSize,
|
||||
cornerWidth,
|
||||
cornerWidth,
|
||||
cornerWidth + lateralPartSize,
|
||||
)
|
||||
const bottomRight = imageDataToUrl(
|
||||
imageData,
|
||||
cornerWidth,
|
||||
cornerWidth,
|
||||
cornerWidth + lateralPartSize,
|
||||
cornerWidth + lateralPartSize,
|
||||
)
|
||||
|
||||
return {
|
||||
topLeft,
|
||||
top,
|
||||
topRight,
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
bottomLeft,
|
||||
bottom,
|
||||
bottomRight,
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/helpers/surface-equations.ts}
|
||||
*/
|
||||
|
||||
export type SurfaceFnDef = (x: number) => number
|
||||
|
||||
export const convexCircle: SurfaceFnDef = x => Math.sqrt(1 - (1 - x) ** 2)
|
||||
|
||||
export const convex: SurfaceFnDef = x => (1 - (1 - x) ** 4) ** (1 / 4)
|
||||
|
||||
export const concave: SurfaceFnDef = x => 1 - convexCircle(x)
|
||||
|
||||
export const lip: SurfaceFnDef = (x) => {
|
||||
const cvx = convex(x * 2)
|
||||
const ccv = concave(x) + 0.1
|
||||
const smootherstep = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3
|
||||
return cvx * (1 - smootherstep) + ccv * smootherstep
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component, StyleValue } from 'vue'
|
||||
|
||||
import type { SurfaceFnDef } from '../helpers/surface-equations'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, useAttrs, useId } from 'vue'
|
||||
|
||||
import Filter from '../components/filter.vue'
|
||||
|
||||
import { convex } from '../helpers/surface-equations'
|
||||
|
||||
interface RefractionOptions {
|
||||
radius: number
|
||||
blur?: number
|
||||
glassThickness?: number
|
||||
bezelWidth?: number
|
||||
refractiveIndex?: number
|
||||
specularOpacity?: number
|
||||
specularAngle?: number
|
||||
bezelHeightFn?: SurfaceFnDef
|
||||
}
|
||||
|
||||
interface RefractiveProps {
|
||||
as?: string | Component
|
||||
refraction: RefractionOptions
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'Refractive',
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<RefractiveProps>(), {
|
||||
as: 'div',
|
||||
})
|
||||
|
||||
defineSlots<{
|
||||
default?: () => unknown
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const filterId = useId()
|
||||
const elementRef = shallowRef<HTMLElement | null>(null)
|
||||
const width = shallowRef(0)
|
||||
const height = shallowRef(0)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const forwardedAttrs = computed(() => {
|
||||
const { style: _style, ...rest } = attrs
|
||||
return rest
|
||||
})
|
||||
|
||||
const mergedStyle = computed<StyleValue>(() => [
|
||||
attrs.style as StyleValue,
|
||||
{
|
||||
backdropFilter: `url(#${filterId})`,
|
||||
borderRadius: `${props.refraction.radius}px`,
|
||||
},
|
||||
])
|
||||
|
||||
const resolvedRefraction = computed(() => ({
|
||||
blur: props.refraction.blur ?? 0,
|
||||
glassThickness: props.refraction.glassThickness ?? 70,
|
||||
bezelWidth: props.refraction.bezelWidth ?? 0,
|
||||
refractiveIndex: props.refraction.refractiveIndex ?? 1.5,
|
||||
specularOpacity: props.refraction.specularOpacity ?? 0,
|
||||
specularAngle: props.refraction.specularAngle ?? 0,
|
||||
bezelHeightFn: props.refraction.bezelHeightFn ?? convex,
|
||||
}))
|
||||
|
||||
function updateSize(entry: ResizeObserverEntry) {
|
||||
const borderBox = Array.isArray(entry.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: entry.borderBoxSize
|
||||
|
||||
if (borderBox) {
|
||||
width.value = borderBox.inlineSize
|
||||
height.value = borderBox.blockSize
|
||||
return
|
||||
}
|
||||
|
||||
width.value = entry.contentRect.width
|
||||
height.value = entry.contentRect.height
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!elementRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
updateSize(entry)
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(elementRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Filter
|
||||
:id="filterId"
|
||||
:scale-ratio="1"
|
||||
:pixel-ratio="6"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:radius="refraction.radius"
|
||||
:blur="resolvedRefraction.blur"
|
||||
:glass-thickness="resolvedRefraction.glassThickness"
|
||||
:bezel-width="resolvedRefraction.bezelWidth"
|
||||
:refractive-index="resolvedRefraction.refractiveIndex"
|
||||
:specular-opacity="resolvedRefraction.specularOpacity"
|
||||
:specular-angle="resolvedRefraction.specularAngle"
|
||||
:bezel-height-fn="resolvedRefraction.bezelHeightFn"
|
||||
/>
|
||||
|
||||
<component
|
||||
:is="as"
|
||||
ref="elementRef"
|
||||
v-bind="forwardedAttrs"
|
||||
:style="mergedStyle"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default as Refractive } from './hoc/refractive.vue'
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/calculate-circle-map.ts}
|
||||
*/
|
||||
|
||||
import type { ProcessPixelFunction } from './process-pixel.type'
|
||||
|
||||
/**
|
||||
* Generates a circular (or rounded rectangle) map and allows processing of each pixel.
|
||||
* Used by Diplacement and Specular maps.
|
||||
*/
|
||||
export function calculateCircleMap(props: {
|
||||
width: number
|
||||
height: number
|
||||
radius: number
|
||||
fillColor: number
|
||||
/** Restricts pixel processing to a certain distance from the border. */
|
||||
maximumDistanceToBorder?: number
|
||||
processPixel: ProcessPixelFunction
|
||||
}) {
|
||||
const { fillColor, processPixel, maximumDistanceToBorder } = props
|
||||
|
||||
const width = Math.round(props.width)
|
||||
const height = Math.round(props.height)
|
||||
const imageData = new ImageData(width, height)
|
||||
|
||||
// Fill buffer with base color
|
||||
new Uint32Array(imageData.data.buffer).fill(fillColor)
|
||||
|
||||
const radius = Math.min(props.radius, width / 2, height / 2)
|
||||
|
||||
const widthBetweenRadiuses = width - radius * 2
|
||||
const heightBetweenRadiuses = height - radius * 2
|
||||
|
||||
const radiusSquared = radius ** 2
|
||||
const radiusPlusOneSquared = (radius + 1) ** 2
|
||||
const radiusMinusBezelSquared = maximumDistanceToBorder
|
||||
? (radius - maximumDistanceToBorder) ** 2
|
||||
: 0
|
||||
|
||||
for (let y1 = 0; y1 < height; y1++) {
|
||||
for (let x1 = 0; x1 < width; x1++) {
|
||||
const idx = (y1 * width + x1) * 4
|
||||
|
||||
const isOnLeftSide = x1 < radius
|
||||
const isOnRightSide = x1 >= width - radius
|
||||
const isOnTopSide = y1 < radius
|
||||
const isOnBottomSide = y1 >= height - radius
|
||||
|
||||
// Virtual x value
|
||||
// When not on sides, value is 0 to stretch circle into rounded rectangle.
|
||||
const x = isOnLeftSide
|
||||
? x1 - radius
|
||||
: isOnRightSide
|
||||
? x1 - radius - widthBetweenRadiuses
|
||||
: 0
|
||||
|
||||
// Virtual y value
|
||||
// When not on sides, value is 0 to stretch circle into rounded rectangle.
|
||||
const y = isOnTopSide
|
||||
? y1 - radius
|
||||
: isOnBottomSide
|
||||
? y1 - radius - heightBetweenRadiuses
|
||||
: 0
|
||||
|
||||
const distanceToCenterSquared = x * x + y * y
|
||||
|
||||
const isInBezel
|
||||
= distanceToCenterSquared <= radiusPlusOneSquared
|
||||
&& distanceToCenterSquared >= radiusMinusBezelSquared
|
||||
|
||||
// Process pixels that are in bezel or near bezel edge for anti-aliasing
|
||||
if (isInBezel) {
|
||||
const distanceFromCenter = Math.sqrt(distanceToCenterSquared)
|
||||
const distanceFromBorder = radius - distanceFromCenter
|
||||
const distanceFromBorderRatio = distanceFromBorder / radius
|
||||
const angle = Math.atan2(y, x)
|
||||
// H-5525: Fix antialiasing calculation
|
||||
const opacity
|
||||
= distanceToCenterSquared > radiusSquared ? 1 - distanceFromBorder : 1
|
||||
|
||||
processPixel(
|
||||
x,
|
||||
y,
|
||||
imageData.data,
|
||||
idx,
|
||||
distanceFromCenter,
|
||||
distanceFromBorder,
|
||||
distanceFromBorderRatio,
|
||||
angle,
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageData
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/calculate-circle-map.ts}
|
||||
*/
|
||||
|
||||
import type { ProcessPixelFunction } from './process-pixel.type'
|
||||
|
||||
import { calculateCircleMap } from './calculate-circle-map'
|
||||
|
||||
/**
|
||||
* Calculates the intersection point on the border of the rounded square,
|
||||
* given by projecting a line from corner center through point (x, y), to the border.
|
||||
*
|
||||
* See this notebook: https://observablehq.com/d/879ebd7e070ed87c
|
||||
*/
|
||||
function calculateBorderIntersection(
|
||||
radius: number,
|
||||
cornerWidth: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const angleStart = Math.atan2(cornerWidth - radius, cornerWidth)
|
||||
const angleEnd = Math.atan2(cornerWidth, cornerWidth - radius)
|
||||
const aperture = angleEnd - angleStart
|
||||
|
||||
const pointAngleInSquare = Math.atan2(Math.abs(y), Math.abs(x))
|
||||
|
||||
if (pointAngleInSquare <= angleStart || pointAngleInSquare >= angleEnd) {
|
||||
// Outside the cone
|
||||
if (Math.abs(y) > Math.abs(x)) {
|
||||
return [
|
||||
Math.abs(x / y) * cornerWidth * Math.sign(x),
|
||||
cornerWidth * Math.sign(y),
|
||||
] as const
|
||||
}
|
||||
else {
|
||||
return [
|
||||
cornerWidth * Math.sign(x),
|
||||
Math.abs(y / x) * cornerWidth * Math.sign(y),
|
||||
] as const
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Inside the cone
|
||||
const pointAngleInCone
|
||||
= (pointAngleInSquare - angleStart) / (aperture / (Math.PI / 2))
|
||||
|
||||
const intersectionX = Math.cos(pointAngleInCone)
|
||||
const intersectionY = Math.sin(pointAngleInCone)
|
||||
|
||||
return [
|
||||
(cornerWidth - radius + intersectionX * radius) * Math.sign(x),
|
||||
(cornerWidth - radius + intersectionY * radius) * Math.sign(y),
|
||||
] as const
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a circular (or rounded rectangle) map and allows processing of each pixel.
|
||||
* Used by Diplacement and Specular maps.
|
||||
*/
|
||||
export function calculateRoundedSquareMap(props: {
|
||||
width: number
|
||||
height: number
|
||||
radius: number
|
||||
fillColor: number
|
||||
/** Restricts pixel processing to a certain distance from the border. */
|
||||
maximumDistanceToBorder?: number
|
||||
processPixel: ProcessPixelFunction
|
||||
}) {
|
||||
if (
|
||||
props.maximumDistanceToBorder === undefined
|
||||
|| props.maximumDistanceToBorder <= props.radius
|
||||
) {
|
||||
return calculateCircleMap(props)
|
||||
}
|
||||
|
||||
const { fillColor, processPixel } = props
|
||||
|
||||
const width = Math.round(props.width)
|
||||
const height = Math.round(props.height)
|
||||
const imageData = new ImageData(width, height)
|
||||
|
||||
// Fill buffer with base color
|
||||
new Uint32Array(imageData.data.buffer).fill(fillColor)
|
||||
|
||||
const radius = Math.min(props.radius, width / 2, height / 2)
|
||||
const cornerWidth = Math.max(
|
||||
radius,
|
||||
Math.min(props.maximumDistanceToBorder, width / 2, height / 2),
|
||||
)
|
||||
|
||||
const widthBetweenCorners = width - cornerWidth * 2
|
||||
const heightBetweenCorners = height - cornerWidth * 2
|
||||
|
||||
// Calculate radiusPlusOne to antialias the border (1 pixel outside)
|
||||
const radiusPlusOne = radius + 1
|
||||
const radiusPlusOneSquared = radiusPlusOne ** 2
|
||||
|
||||
// Iterate over every pixel in the buffer
|
||||
for (let bufferY = 0; bufferY < height; bufferY++) {
|
||||
for (let bufferX = 0; bufferX < width; bufferX++) {
|
||||
const idx = (bufferY * width + bufferX) * 4
|
||||
|
||||
const isOnLeftSide = bufferX < cornerWidth
|
||||
const isOnRightSide = bufferX >= width - cornerWidth
|
||||
const isOnTopSide = bufferY < cornerWidth
|
||||
const isOnBottomSide = bufferY >= height - cornerWidth
|
||||
|
||||
// Virtual x value
|
||||
// When not on sides, value is 0 to stretch circle into rounded rectangle.
|
||||
const x = isOnLeftSide
|
||||
? bufferX - cornerWidth
|
||||
: isOnRightSide
|
||||
? bufferX - cornerWidth - widthBetweenCorners
|
||||
: 0
|
||||
|
||||
// Virtual y value
|
||||
// When not on sides, value is 0 to stretch circle into rounded rectangle.
|
||||
const y = isOnTopSide
|
||||
? bufferY - cornerWidth
|
||||
: isOnBottomSide
|
||||
? bufferY - cornerWidth - heightBetweenCorners
|
||||
: 0
|
||||
|
||||
// Find the intersection point on the border of the rounded square
|
||||
const [intersectionX, intersectionY] = calculateBorderIntersection(
|
||||
radius,
|
||||
cornerWidth,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
|
||||
const distanceToCenterSquared = x * x + y * y
|
||||
const distanceToBorderSquared
|
||||
= (intersectionX - x) ** 2 + (intersectionY - y) ** 2
|
||||
|
||||
// If the pixel is within corner square containing the quarter circle
|
||||
const isInRadiusSquare
|
||||
= Math.abs(x) > cornerWidth - radius
|
||||
&& Math.abs(y) > cornerWidth - radius
|
||||
|
||||
// If the pixel is within corner square, but outside the quarter circle
|
||||
const isOutsideRadius
|
||||
= isInRadiusSquare
|
||||
&& (Math.abs(x) - (cornerWidth - radius)) ** 2
|
||||
+ (Math.abs(y) - (cornerWidth - radius)) ** 2
|
||||
>= radiusPlusOneSquared
|
||||
|
||||
const isInRoundedSquare = !isInRadiusSquare || !isOutsideRadius
|
||||
|
||||
// Process pixels that are in rounded square or near bezel edge for anti-aliasing
|
||||
if (isInRoundedSquare) {
|
||||
const distanceFromCenter = Math.sqrt(distanceToCenterSquared)
|
||||
const distanceFromBorder = Math.sqrt(distanceToBorderSquared)
|
||||
const distanceFromBorderRatio
|
||||
= distanceFromBorder / (distanceFromCenter + distanceFromBorder)
|
||||
const angle = Math.atan2(y, x)
|
||||
// H-5525: Fix antialiasing calculation
|
||||
const opacity = isOutsideRadius ? 1 - distanceFromBorder : 1
|
||||
|
||||
processPixel(
|
||||
x,
|
||||
y,
|
||||
imageData.data,
|
||||
idx,
|
||||
distanceFromCenter,
|
||||
distanceFromBorder,
|
||||
distanceFromBorderRatio,
|
||||
angle,
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/displacement-map.ts}
|
||||
*/
|
||||
|
||||
import { calculateRoundedSquareMap } from './calculate-rounded-square-map'
|
||||
|
||||
export function calculateDisplacementMapRadius(
|
||||
glassThickness: number = 200,
|
||||
bezelWidth: number = 50,
|
||||
bezelHeightFn: (x: number) => number = x => x,
|
||||
refractiveIndex: number = 1.5,
|
||||
samples: number = 128,
|
||||
): number[] {
|
||||
// Pre-calculate the distance the ray will be deviated
|
||||
// given the distance to border (ratio of bezel)
|
||||
// and height of the glass
|
||||
const eta = 1 / refractiveIndex
|
||||
|
||||
// Simplified refraction, which only handles fully vertical incident ray [0, 1]
|
||||
function refract(normalX: number, normalY: number): [number, number] | null {
|
||||
const dot = normalY
|
||||
const k = 1 - eta * eta * (1 - dot * dot)
|
||||
if (k < 0) {
|
||||
// Total internal reflection
|
||||
return null
|
||||
}
|
||||
const kSqrt = Math.sqrt(k)
|
||||
return [
|
||||
-(eta * dot + kSqrt) * normalX,
|
||||
eta - (eta * dot + kSqrt) * normalY,
|
||||
] as const
|
||||
}
|
||||
|
||||
return Array.from({ length: samples }, (_, i) => {
|
||||
const x = i / samples
|
||||
const y = bezelHeightFn(x)
|
||||
|
||||
// Calculate derivative in x
|
||||
const dx = x < 1 ? 0.0001 : -0.0001
|
||||
const y2 = bezelHeightFn(x + dx)
|
||||
const derivative = (y2 - y) / dx
|
||||
const magnitude = Math.sqrt(derivative * derivative + 1)
|
||||
const normal = [-derivative / magnitude, -1 / magnitude] as const
|
||||
const refracted = refract(normal[0], normal[1])
|
||||
|
||||
if (!refracted) {
|
||||
return 0
|
||||
}
|
||||
else {
|
||||
const remainingHeightOnBezel = y * bezelWidth
|
||||
const remainingHeight = remainingHeightOnBezel + glassThickness
|
||||
|
||||
// Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom of glass)
|
||||
return refracted[0] * (remainingHeight / refracted[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function calculateDisplacementMap(props: {
|
||||
width: number
|
||||
height: number
|
||||
radius: number
|
||||
bezelWidth: number
|
||||
maximumDisplacement: number
|
||||
precomputedDisplacementMap: number[]
|
||||
pixelRatio: number
|
||||
}) {
|
||||
const { pixelRatio, maximumDisplacement, precomputedDisplacementMap } = props
|
||||
|
||||
const width = Math.round(props.width * pixelRatio)
|
||||
const height = Math.round(props.height * pixelRatio)
|
||||
|
||||
const radius = Math.min(props.radius * pixelRatio, width / 2, height / 2)
|
||||
const bezel = Math.min(props.bezelWidth * pixelRatio, width / 2, height / 2)
|
||||
|
||||
return calculateRoundedSquareMap({
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
maximumDistanceToBorder: bezel,
|
||||
fillColor: 0xFF008080,
|
||||
processPixel(
|
||||
_x,
|
||||
_y,
|
||||
buffer,
|
||||
offset,
|
||||
_distanceFromCenter,
|
||||
distanceFromBorder,
|
||||
distanceFromBorderRatio,
|
||||
angle,
|
||||
opacity,
|
||||
) {
|
||||
// Viewed from top
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
|
||||
const ratio
|
||||
= bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel
|
||||
|
||||
const bezelIndex = Math.round(ratio * precomputedDisplacementMap.length)
|
||||
const distance = precomputedDisplacementMap[bezelIndex] ?? 0
|
||||
|
||||
const dX = (-cos * distance) / maximumDisplacement
|
||||
const dY = (-sin * distance) / maximumDisplacement
|
||||
|
||||
buffer[offset] = 128 + dX * 127 * opacity // R
|
||||
buffer[offset + 1] = 128 + dY * 127 * opacity // G
|
||||
buffer[offset + 2] = 0 // B
|
||||
buffer[offset + 3] = 255 // A
|
||||
},
|
||||
})
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/process-pixel.type.ts}
|
||||
*/
|
||||
|
||||
export type ProcessPixelFunction = (
|
||||
x: number,
|
||||
y: number,
|
||||
buffer: Uint8ClampedArray<ArrayBufferLike>,
|
||||
offset: number,
|
||||
distanceFromCenter: number,
|
||||
distanceFromBorder: number,
|
||||
distanceFromBorderRatio: number,
|
||||
/**
|
||||
* Angle from center to pixel in radians.
|
||||
*/
|
||||
angle: number,
|
||||
opacity: number,
|
||||
) => void
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @see @{@link https://github.com/hashintel/hash/blob/b03f6fe875220edd0f01ae4626ed223d3cf663ed/libs/%40hashintel/refractive/src/maps/specular.ts}
|
||||
*/
|
||||
|
||||
import { calculateCircleMap } from './calculate-circle-map'
|
||||
|
||||
const NEAR_EDGE_DISTANCE = 20
|
||||
|
||||
export function calculateSpecularImage(props: {
|
||||
width: number
|
||||
height: number
|
||||
radius: number
|
||||
specularAngle: number
|
||||
pixelRatio: number
|
||||
}) {
|
||||
const { pixelRatio, specularAngle } = props
|
||||
|
||||
// Calculate real dimensions using pixel ratio
|
||||
const width = Math.round(props.width * pixelRatio)
|
||||
const height = Math.round(props.height * pixelRatio)
|
||||
const radius = Math.min(props.radius * pixelRatio, width / 2, height / 2)
|
||||
|
||||
// Vector along which we should see specular
|
||||
const specular_vector = [
|
||||
Math.cos(specularAngle),
|
||||
Math.sin(specularAngle),
|
||||
] as const
|
||||
|
||||
return calculateCircleMap({
|
||||
width,
|
||||
height,
|
||||
fillColor: 0x00000000,
|
||||
radius,
|
||||
maximumDistanceToBorder: NEAR_EDGE_DISTANCE * pixelRatio,
|
||||
processPixel(
|
||||
x,
|
||||
y,
|
||||
buffer,
|
||||
offset,
|
||||
distanceFromCenter,
|
||||
_distanceFromBorder,
|
||||
_distanceFromBorderRatio,
|
||||
_angle,
|
||||
opacity,
|
||||
) {
|
||||
const distanceFromSide = radius - distanceFromCenter
|
||||
|
||||
// Viewed from top
|
||||
const cos = x / distanceFromCenter
|
||||
const sin = -y / distanceFromCenter
|
||||
|
||||
// Dot product of orientation
|
||||
const dotProduct = Math.abs(
|
||||
cos * specular_vector[0] + sin * specular_vector[1],
|
||||
)
|
||||
|
||||
const coefficient
|
||||
= dotProduct
|
||||
* Math.sqrt(1 - (1 - distanceFromSide / (1 * pixelRatio)) ** 2)
|
||||
|
||||
const color = 255 * coefficient
|
||||
const finalOpacity = color * coefficient * opacity
|
||||
|
||||
buffer[offset] = color // R
|
||||
buffer[offset + 1] = color // G
|
||||
buffer[offset + 2] = color // B
|
||||
buffer[offset + 3] = finalOpacity // A
|
||||
},
|
||||
})
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /System/Applications/Apps.app/Contents/Resources
|
||||
*/
|
||||
import AppsIcon from './assets/apps.png'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="AppsIcon" alt="Apps Icon">
|
||||
</template>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /System/Library/CoreServices/Finder.app/Contents/Resources
|
||||
*/
|
||||
import FinderIcon from './assets/finder.png'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="FinderIcon" alt="Finder Icon">
|
||||
</template>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /System/Library/CoreServices/Dock.app/Contents/Resources
|
||||
*/
|
||||
import TrashFullIcon from './assets/trash-full.png'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="TrashFullIcon" alt="Trash Full Icon">
|
||||
</template>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
+3
@@ -0,0 +1,3 @@
|
||||
export { default as Apps } from './Apps.vue'
|
||||
export { default as Finder } from './Finder.vue'
|
||||
export { default as TrashFull } from './TrashFull.vue'
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center',
|
||||
]"
|
||||
>
|
||||
<div i-mdi:apple />
|
||||
</div>
|
||||
</template>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex flex-col justify-center items-center',
|
||||
'relative',
|
||||
'size-4 m-1 translate-y--0.8',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute order-1',
|
||||
'text-base scale-75',
|
||||
'i-mdi:toggle-switch',
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
:class="[
|
||||
'translate-y-1.75 absolute order-2',
|
||||
'text-base scale-67',
|
||||
'i-mdi:toggle-switch-off-outline',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex flex-col justify-center items-center',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'i-material-symbols:android-wifi-3-bar',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { default as Apple } from './Apple.vue'
|
||||
export { default as Switch2_0 } from './Switch2.0.vue'
|
||||
export { default as Wifi } from './Wifi.vue'
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import FolderIcon from './assets/folder-common.png'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="FolderIcon" alt="Folder Icon">
|
||||
</template>
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
+1
@@ -0,0 +1 @@
|
||||
export { default as Folder } from './Folder.vue'
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PlatformRoot } from './PlatformRoot.vue'
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import wallpaper from '../../../../../assets/wallpaper-2025-08-12.jpg'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
:class="[
|
||||
'absolute left-0 top-0 h-full w-full',
|
||||
'z-1',
|
||||
]"
|
||||
:src="wallpaper"
|
||||
>
|
||||
</template>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { Application, DockDivider, DockRoot } from '../../containers/dock'
|
||||
import { Refractive } from '../../graphics'
|
||||
import { Apps, Finder, TrashFull } from '../../icons/applications'
|
||||
import { Folder } from '../../icons/system'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 2,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute right-2 top-1/2 z-1000 translate-y--1/2">
|
||||
<DockRoot :size="props.size">
|
||||
<Refractive
|
||||
:refraction="{
|
||||
radius: 16 * props.size * 0.75,
|
||||
blur: 6,
|
||||
glassThickness: 70,
|
||||
bezelWidth: 8,
|
||||
refractiveIndex: 1.5,
|
||||
specularOpacity: 0.25,
|
||||
specularAngle: Math.PI / 4,
|
||||
}"
|
||||
:style="{
|
||||
background: 'rgb(255 255 255 / 0.12)',
|
||||
border: '1px solid rgb(255 255 255 / 0.1)',
|
||||
}"
|
||||
:class="[
|
||||
'p-1.5',
|
||||
'flex flex-col justify-center items-center',
|
||||
]"
|
||||
>
|
||||
<Application running>
|
||||
<Finder />
|
||||
</Application>
|
||||
<Application>
|
||||
<Apps />
|
||||
</Application>
|
||||
<slot name="dock" />
|
||||
<DockDivider />
|
||||
<Application :base-size="28">
|
||||
<Folder />
|
||||
</Application>
|
||||
<Application>
|
||||
<TrashFull />
|
||||
</Application>
|
||||
</Refractive>
|
||||
</DockRoot>
|
||||
</div>
|
||||
</template>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { default as Appearance } from './display/appearance.vue'
|
||||
export { default as Dock } from './dock/dock.vue'
|
||||
export { default as MenuBar } from './menu-bar/menu-bar.vue'
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { Apple, Switch2_0, Wifi } from '../../icons/sf-symbol-like'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-0 left-0',
|
||||
'z-1000',
|
||||
'w-full py-1 px-5',
|
||||
'flex items-center',
|
||||
'bg-white/40 dark:bg-black/40',
|
||||
]"
|
||||
>
|
||||
<div class="w-full flex items-center gap-4">
|
||||
<Apple />
|
||||
<div :class="['text-xs', 'font-600']">
|
||||
<span>AIRI</span>
|
||||
</div>
|
||||
<div :class="['text-xs']">
|
||||
<span>File</span>
|
||||
</div>
|
||||
<div :class="['text-xs']">
|
||||
<span>Edit</span>
|
||||
</div>
|
||||
<div :class="['text-xs']">
|
||||
<span>Selection</span>
|
||||
</div>
|
||||
<div :class="['text-xs']">
|
||||
<span>View</span>
|
||||
</div>
|
||||
<div :class="['text-xs']">
|
||||
<span>Window</span>
|
||||
</div>
|
||||
<div :class="['text-xs']">
|
||||
<span>Help</span>
|
||||
</div>
|
||||
<div class="w-full flex-1" />
|
||||
<Wifi />
|
||||
<Switch2_0 />
|
||||
<div :class="['text-xs']">
|
||||
<span>Fri Apr 3 22:04:05</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import 'uno.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { markScenarioReady, resetScenarioReady } from '@proj-airi/vishot-runtime'
|
||||
import { ScenarioCanvas, ScenarioCaptureRoot } from '@proj-airi/vishot-runtime/vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import stageShot from '../../artifacts/raw/00-stage-tamagotchi.png'
|
||||
import websocketSettingsShot from '../../artifacts/raw/04-websocket-settings.png'
|
||||
import Icon from '../components/icon.vue'
|
||||
|
||||
import { PlatformRoot } from '../components/platforms/macos-26'
|
||||
import { Application } from '../components/platforms/macos-26/containers/dock'
|
||||
import { WindowRoot } from '../components/platforms/macos-26/containers/window'
|
||||
|
||||
async function waitForImageSource(source: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
|
||||
image.addEventListener('load', () => resolve(), { once: true })
|
||||
image.addEventListener('error', () => reject(new Error(`Scenario image failed to load: ${source}`)), { once: true })
|
||||
image.src = source
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
resetScenarioReady()
|
||||
try {
|
||||
await Promise.all([
|
||||
document.fonts.ready,
|
||||
waitForImageSource(stageShot),
|
||||
waitForImageSource(websocketSettingsShot),
|
||||
])
|
||||
markScenarioReady()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScenarioCanvas>
|
||||
<ScenarioCaptureRoot name="intro-chat-window">
|
||||
<PlatformRoot :dock-size="1.5">
|
||||
<template #windows>
|
||||
<WindowRoot class="translate-x-300 translate-y-100" :frame="false" :has-shadow="false">
|
||||
<img :src="stageShot" class="w-95">
|
||||
</WindowRoot>
|
||||
<WindowRoot class="translate-x-120 translate-y-30">
|
||||
<img :src="websocketSettingsShot" class="w-120">
|
||||
</WindowRoot>
|
||||
</template>
|
||||
<template #dock>
|
||||
<Application running>
|
||||
<Icon />
|
||||
</Application>
|
||||
</template>
|
||||
</PlatformRoot>
|
||||
</ScenarioCaptureRoot>
|
||||
</ScenarioCanvas>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ESNext",
|
||||
"DOM.AsyncIterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mergeConfigs, presetWebFonts } from 'unocss'
|
||||
|
||||
import { presetWebFontsFonts, sharedUnoConfig } from '../../uno.config'
|
||||
|
||||
export default mergeConfigs([
|
||||
sharedUnoConfig(),
|
||||
{
|
||||
presets: [
|
||||
presetWebFonts({
|
||||
fonts: {
|
||||
...presetWebFontsFonts('fontsource'),
|
||||
},
|
||||
timeouts: {
|
||||
warning: 5000,
|
||||
failure: 10000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,14 @@
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Unocss from 'unocss/vite'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
Vue(),
|
||||
|
||||
// https://github.com/antfu/unocss
|
||||
// see uno.config.ts for config
|
||||
Unocss(),
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
# Scenarios - Stage Tamagotchi Electron
|
||||
|
||||
Own the raw Electron capture scenarios for stage-tamagotchi.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package owns the product-specific scenario definitions only. It depends on `@proj-airi/vishot-runner-electron` for:
|
||||
|
||||
- the `defineScenario()` helper
|
||||
- the capture context surface
|
||||
- Electron window and screenshot helpers exposed by the runner package
|
||||
|
||||
It does not launch Electron itself and it does not compose final browser-scene exports.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build the Electron app.
|
||||
2. Run the runner package against one of the scenario modules in this package.
|
||||
3. Write raw screenshots into `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`.
|
||||
4. Let the browser scene package consume those raw screenshots for final composition.
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/stage-tamagotchi build
|
||||
pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir packages/scenarios-stage-tamagotchi-browser/artifacts/raw
|
||||
```
|
||||
|
||||
## Scenario Authoring
|
||||
|
||||
```ts
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'settings-connection',
|
||||
async run({ capture, stageWindows, controlsIsland, settingsWindow }) {
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
|
||||
await controlsIsland.expand(mainWindow.page)
|
||||
const settings = await controlsIsland.openSettings(mainWindow.page)
|
||||
const page = await settingsWindow.goToConnection(settings.page)
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
await page.getByText('WebSocket Server Address').waitFor({ state: 'visible' })
|
||||
await capture('connection-settings', page)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Raw scenario modules live under `src/scenarios`.
|
||||
- Scenario modules are consumed by the runner package, not by the browser composition package.
|
||||
- The package stays focused on business capture flows and avoids browser-scene composition concerns.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@proj-airi/scenarios-stage-tamagotchi-electron",
|
||||
"type": "module",
|
||||
"version": "0.9.0-beta.0",
|
||||
"private": true,
|
||||
"description": "Product-owned Electron scenarios for stage-tamagotchi raw screenshot capture",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@proj-airi/vishot-runner-electron": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket'
|
||||
export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces'
|
||||
export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog'
|
||||
export { default as settingsConnectionScenario } from './scenarios/settings-connection'
|
||||
+7
-10
@@ -1,31 +1,28 @@
|
||||
/* eslint-disable e18e/prefer-static-regex */
|
||||
import { defineScenario } from '../runtime/define-scenario'
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'demo-controls-settings-chat-websocket',
|
||||
async run({ capture, controlsIsland, settingsWindow, stageWindows }) {
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
await mainWindow.page.waitForTimeout(2000)
|
||||
await capture('00-stage-tamagotchi', mainWindow.page)
|
||||
|
||||
await controlsIsland.expand(mainWindow.page)
|
||||
await mainWindow.page.waitForTimeout(300)
|
||||
|
||||
await capture('00-controls-island-expanded', mainWindow.page)
|
||||
await capture('01-controls-island-expanded', mainWindow.page)
|
||||
|
||||
const settingsWindowSnapshot = await controlsIsland.openSettings(mainWindow.page)
|
||||
await settingsWindowSnapshot.page.getByText(/connection|websocket|router/i).first().waitFor({ state: 'visible' })
|
||||
await settingsWindowSnapshot.page.waitForTimeout(300)
|
||||
await capture('01-settings-window', settingsWindowSnapshot.page)
|
||||
await capture('02-settings-window', settingsWindowSnapshot.page)
|
||||
|
||||
await mainWindow.page.bringToFront()
|
||||
await controlsIsland.expand(mainWindow.page)
|
||||
const chatWindowSnapshot = await controlsIsland.openChat(mainWindow.page)
|
||||
await chatWindowSnapshot.page.waitForLoadState('domcontentloaded')
|
||||
await chatWindowSnapshot.page.waitForTimeout(300)
|
||||
await capture('02-chat-window', chatWindowSnapshot.page)
|
||||
|
||||
const websocketSettingsPage = await settingsWindow.goToConnection(settingsWindowSnapshot.page)
|
||||
await websocketSettingsPage.getByText('WebSocket Server Address').waitFor({ state: 'visible' })
|
||||
await websocketSettingsPage.waitForTimeout(300)
|
||||
await capture('03-websocket-settings', websocketSettingsPage)
|
||||
await websocketSettingsPage.waitForTimeout(750)
|
||||
await capture('04-websocket-settings', websocketSettingsPage)
|
||||
},
|
||||
})
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineScenario } from '../runtime/define-scenario'
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'demo-dismiss-surfaces',
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineScenario } from '../runtime/define-scenario'
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'demo-hearing-dialog',
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineScenario } from '../runtime/define-scenario'
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'settings-connection',
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Vishot Runner Browser
|
||||
|
||||
Browser-side capture tooling for Vishot scene rendering entrypoints.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package is the browser capture engine used by scene packages such as `@proj-airi/scenarios-stage-tamagotchi-browser`. It provides:
|
||||
|
||||
- the package export surface in `src/index.ts`
|
||||
- the `captureBrowserRoots()` programmatic API
|
||||
- the `capture` CLI entry in `src/cli/capture.ts`
|
||||
- Vite dev-server startup for scene packages
|
||||
- Playwright-driven export of each `data-scenario-capture-root` element as its own PNG
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/vishot-runner-browser capture -- ../scenarios-stage-tamagotchi-browser --output-dir ../scenarios-stage-tamagotchi-browser/artifacts/final
|
||||
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture
|
||||
```
|
||||
|
||||
The browser capture package can run directly against a scene package root, or indirectly through the consumer package's own `capture` script. In both cases it starts the scene package's Vite app, opens it in Chromium, waits for the frontend ready signal, and exports each capture root into `packages/scenarios-stage-tamagotchi-browser/artifacts/final`.
|
||||
|
||||
Programmatic usage from a scene package looks like this:
|
||||
|
||||
```ts
|
||||
import path from 'node:path'
|
||||
|
||||
import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser'
|
||||
|
||||
const sceneAppRoot = path.resolve(process.cwd())
|
||||
|
||||
await captureBrowserRoots({
|
||||
sceneAppRoot,
|
||||
routePath: '/',
|
||||
outputDir: path.resolve(sceneAppRoot, 'artifacts', 'final'),
|
||||
})
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `captureBrowserRoots()` accepts either `sceneAppRoot` or `baseUrl`.
|
||||
- Scene packages mark readiness through `window.__SCENARIO_CAPTURE_READY__`.
|
||||
- The CLI treats `<render-entry>` as the scene package root and captures the default `/` route.
|
||||
- The parser accepts repeated `--root` flags for named capture roots.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@proj-airi/vishot-runner-browser",
|
||||
"type": "module",
|
||||
"version": "0.9.0-beta.4",
|
||||
"private": true,
|
||||
"description": "Browser capture runner for Vishot scenes",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/vishot-runner-browser"
|
||||
},
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"capture": "tsx src/cli/capture.ts",
|
||||
"test:run": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@moeru/std": "catalog:",
|
||||
"@types/node": "^24.12.0",
|
||||
"meow": "catalog:",
|
||||
"playwright": "^1.56.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:vitest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseCaptureBrowserCliArguments } from './capture'
|
||||
|
||||
describe('parseCaptureBrowserCliArguments', () => {
|
||||
it('accepts a render entry and output dir', () => {
|
||||
expect(parseCaptureBrowserCliArguments([
|
||||
'src/scenes/intro.ts',
|
||||
'--output-dir',
|
||||
'./artifacts/browser-run',
|
||||
])).toEqual({
|
||||
renderEntry: 'src/scenes/intro.ts',
|
||||
outputDir: './artifacts/browser-run',
|
||||
rootNames: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the -o alias and repeated --root flags', () => {
|
||||
expect(parseCaptureBrowserCliArguments([
|
||||
'src/scenes/intro.ts',
|
||||
'-o',
|
||||
'./artifacts/browser-run',
|
||||
'--root',
|
||||
'intro-desktop',
|
||||
'--root',
|
||||
'intro-settings',
|
||||
])).toEqual({
|
||||
renderEntry: 'src/scenes/intro.ts',
|
||||
outputDir: './artifacts/browser-run',
|
||||
rootNames: ['intro-desktop', 'intro-settings'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing output dir', () => {
|
||||
expect(() => parseCaptureBrowserCliArguments([
|
||||
'src/scenes/intro.ts',
|
||||
])).toThrow('Usage: vishot-runner-browser <render-entry> --output-dir <dir>')
|
||||
})
|
||||
|
||||
it('rejects extra positional arguments', () => {
|
||||
expect(() => parseCaptureBrowserCliArguments([
|
||||
'src/scenes/intro.ts',
|
||||
'src/scenes/demo.ts',
|
||||
'--output-dir',
|
||||
'./artifacts/browser-run',
|
||||
])).toThrow('Usage: vishot-runner-browser <render-entry> --output-dir <dir>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CaptureBrowserCliArguments } from '../runtime/types'
|
||||
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import meow from 'meow'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
import { captureBrowserRoots } from '../runtime/capture'
|
||||
|
||||
const captureBrowserHelpText = `
|
||||
Capture browser-rendered scenes and export named roots.
|
||||
|
||||
Usage
|
||||
$ vishot-runner-browser <render-entry> --output-dir <dir>
|
||||
|
||||
Options
|
||||
--output-dir, -o Directory to write browser capture output into
|
||||
--root Named capture root to export; may be repeated
|
||||
`
|
||||
|
||||
const captureBrowserUsageMessage = 'Usage: vishot-runner-browser <render-entry> --output-dir <dir>'
|
||||
|
||||
function normalizeCliArgv(argv: string[]): string[] {
|
||||
return argv[0] === '--' ? argv.slice(1) : argv
|
||||
}
|
||||
|
||||
function normalizeRootNames(rootNames: string | string[] | undefined): string[] {
|
||||
if (typeof rootNames === 'string') {
|
||||
return [rootNames]
|
||||
}
|
||||
|
||||
return rootNames ?? []
|
||||
}
|
||||
|
||||
export function parseCaptureBrowserCliArguments(argv: string[]): CaptureBrowserCliArguments {
|
||||
const cli = meow(captureBrowserHelpText, {
|
||||
argv: normalizeCliArgv(argv),
|
||||
importMeta: import.meta,
|
||||
flags: {
|
||||
outputDir: {
|
||||
shortFlag: 'o',
|
||||
type: 'string',
|
||||
},
|
||||
root: {
|
||||
isMultiple: true,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (cli.input.length !== 1
|
||||
|| typeof cli.flags.outputDir !== 'string'
|
||||
|| cli.flags.outputDir.length === 0) {
|
||||
throw new Error(captureBrowserUsageMessage)
|
||||
}
|
||||
|
||||
return {
|
||||
renderEntry: cli.input[0],
|
||||
outputDir: cli.flags.outputDir,
|
||||
rootNames: normalizeRootNames(cli.flags.root),
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { outputDir, renderEntry, rootNames } = parseCaptureBrowserCliArguments(process.argv.slice(2))
|
||||
|
||||
await captureBrowserRoots({
|
||||
sceneAppRoot: path.resolve(process.cwd(), renderEntry),
|
||||
routePath: '/',
|
||||
outputDir: path.resolve(process.cwd(), outputDir),
|
||||
rootNames,
|
||||
})
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
void main().catch((error) => {
|
||||
console.error(errorMessageFrom(error) ?? captureBrowserUsageMessage)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { parseCaptureBrowserCliArguments } from './cli/capture'
|
||||
export { captureBrowserRoots } from './runtime/capture'
|
||||
export { assertUniqueCaptureFilePaths, captureFilePath, sanitizeOutputName } from './runtime/files'
|
||||
export { captureRootSelector } from './runtime/selectors'
|
||||
export type { CaptureBrowserCliArguments } from './runtime/types'
|
||||
export type { BrowserCaptureRequest, CapturedRootArtifact } from './runtime/types'
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { assertUniqueCaptureFilePaths, sanitizeOutputName } from './files'
|
||||
import { captureRootSelector } from './selectors'
|
||||
|
||||
describe('sanitizeOutputName', () => {
|
||||
it('normalizes invalid filename characters', () => {
|
||||
expect(sanitizeOutputName('Intro Desktop / Main')).toBe('intro-desktop-main')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertUniqueCaptureFilePaths', () => {
|
||||
it('allows unique sanitized output names', () => {
|
||||
expect(() => assertUniqueCaptureFilePaths([
|
||||
'intro-desktop',
|
||||
'intro-settings',
|
||||
])).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects colliding sanitized output names', () => {
|
||||
expect(() => assertUniqueCaptureFilePaths([
|
||||
'Foo Bar',
|
||||
'foo/bar',
|
||||
])).toThrow('both resolve to "foo-bar.png"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureRootSelector', () => {
|
||||
it('builds the root selector for a named capture root', () => {
|
||||
expect(captureRootSelector('intro-desktop')).toBe('[data-scenario-capture-root="intro-desktop"]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
import type { BrowserCaptureRequest, CapturedRootArtifact } from './types'
|
||||
|
||||
import path from 'node:path'
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import { assertUniqueCaptureFilePaths, captureFilePath } from './files'
|
||||
import { captureRootSelector } from './selectors'
|
||||
import { startSceneViteServer } from './vite-server'
|
||||
|
||||
const defaultViewport = {
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
deviceScaleFactor: 2,
|
||||
} as const
|
||||
|
||||
function getScenarioCaptureRoots(page: Page): Promise<string[]> {
|
||||
return page.locator('[data-scenario-capture-root]').evaluateAll((elements) => {
|
||||
const rootNames = elements
|
||||
.map(element => element.getAttribute('data-scenario-capture-root') ?? '')
|
||||
.filter((name): name is string => name.length > 0)
|
||||
|
||||
return [...new Set(rootNames)]
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForScenarioReady(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => {
|
||||
return (window as typeof window & { __SCENARIO_CAPTURE_READY__?: boolean }).__SCENARIO_CAPTURE_READY__ === true
|
||||
})
|
||||
}
|
||||
|
||||
async function captureRoot(page: Page, outputDir: string, rootName: string): Promise<CapturedRootArtifact> {
|
||||
const filePath = captureFilePath(outputDir, rootName)
|
||||
const locator = page.locator(captureRootSelector(rootName))
|
||||
|
||||
await locator.waitFor({ state: 'visible' })
|
||||
await locator.screenshot({
|
||||
animations: 'disabled',
|
||||
path: filePath,
|
||||
})
|
||||
|
||||
return {
|
||||
rootName,
|
||||
filePath,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseUrl(request: BrowserCaptureRequest): Promise<{ baseUrl: string, closeServer?: () => Promise<void> }> {
|
||||
if (request.baseUrl) {
|
||||
return { baseUrl: request.baseUrl }
|
||||
}
|
||||
|
||||
if (request.sceneAppRoot) {
|
||||
const server = await startSceneViteServer(request.sceneAppRoot)
|
||||
|
||||
return {
|
||||
baseUrl: server.baseUrl,
|
||||
closeServer: server.close,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Browser capture requires either "baseUrl" or "sceneAppRoot"')
|
||||
}
|
||||
|
||||
export async function captureBrowserRoots(request: BrowserCaptureRequest): Promise<CapturedRootArtifact[]> {
|
||||
const { baseUrl, closeServer } = await resolveBaseUrl(request)
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({
|
||||
viewport: {
|
||||
width: request.viewport?.width ?? defaultViewport.width,
|
||||
height: request.viewport?.height ?? defaultViewport.height,
|
||||
},
|
||||
deviceScaleFactor: request.viewport?.deviceScaleFactor ?? defaultViewport.deviceScaleFactor,
|
||||
})
|
||||
|
||||
try {
|
||||
const page = await context.newPage()
|
||||
const targetUrl = new URL(request.routePath, baseUrl).href
|
||||
|
||||
await page.goto(targetUrl)
|
||||
await waitForScenarioReady(page)
|
||||
await mkdir(path.resolve(request.outputDir), { recursive: true })
|
||||
|
||||
const rootNames = request.rootNames?.length
|
||||
? request.rootNames
|
||||
: await getScenarioCaptureRoots(page)
|
||||
|
||||
assertUniqueCaptureFilePaths(rootNames)
|
||||
|
||||
const artifacts: CapturedRootArtifact[] = []
|
||||
|
||||
for (const rootName of rootNames) {
|
||||
artifacts.push(await captureRoot(page, request.outputDir, rootName))
|
||||
}
|
||||
|
||||
return artifacts
|
||||
}
|
||||
finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
if (browser) {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (closeServer) {
|
||||
await closeServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import path from 'node:path'
|
||||
|
||||
const nonFilenameCharactersPattern = /[^a-z0-9-_]+/g
|
||||
const edgeDashPattern = /^-+|-+$/g
|
||||
|
||||
export function sanitizeOutputName(name: string): string {
|
||||
const sanitized = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(nonFilenameCharactersPattern, '-')
|
||||
.replace(edgeDashPattern, '')
|
||||
|
||||
return sanitized.length > 0 ? sanitized : 'capture'
|
||||
}
|
||||
|
||||
export function captureFilePath(outputDir: string, rootName: string): string {
|
||||
return path.resolve(outputDir, `${sanitizeOutputName(rootName)}.png`)
|
||||
}
|
||||
|
||||
export function assertUniqueCaptureFilePaths(rootNames: string[]): void {
|
||||
const seenFilePaths = new Map<string, string>()
|
||||
|
||||
for (const rootName of rootNames) {
|
||||
const sanitizedName = sanitizeOutputName(rootName)
|
||||
const previousRootName = seenFilePaths.get(sanitizedName)
|
||||
|
||||
if (previousRootName) {
|
||||
throw new Error(
|
||||
`Capture roots "${previousRootName}" and "${rootName}" both resolve to "${sanitizedName}.png". Root names must map to unique output files.`,
|
||||
)
|
||||
}
|
||||
|
||||
seenFilePaths.set(sanitizedName, rootName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface FakeLocator {
|
||||
evaluateAll: <T>(callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) => Promise<T>
|
||||
waitFor: (opts: { state: 'visible' }) => Promise<void>
|
||||
screenshot: (opts: { animations: 'disabled', path: string }) => Promise<void>
|
||||
}
|
||||
|
||||
interface FakePage {
|
||||
goto: (url: string) => Promise<void>
|
||||
waitForFunction: (predicate: () => boolean) => Promise<void>
|
||||
locator: (selector: string) => FakeLocator
|
||||
}
|
||||
|
||||
function createFixturePage(html: string): FakePage {
|
||||
const readyState = {
|
||||
ready: false,
|
||||
}
|
||||
|
||||
function getRootNames(): string[] {
|
||||
return Array.from(html.matchAll(/data-scenario-capture-root="([^"]+)"/g), match => match[1])
|
||||
}
|
||||
|
||||
function hasRoot(selector: string): boolean {
|
||||
const match = selector.match(/^\[data-scenario-capture-root="([^"]+)"\]$/)
|
||||
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getRootNames().includes(match[1])
|
||||
}
|
||||
|
||||
return {
|
||||
async goto(url: string) {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load scene fixture: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
html = await response.text()
|
||||
readyState.ready = /__SCENARIO_CAPTURE_READY__\s*=\s*true/.test(html)
|
||||
},
|
||||
async waitForFunction(predicate: () => boolean) {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt <= 5000) {
|
||||
const previousWindow = globalThis.window
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
__SCENARIO_CAPTURE_READY__: readyState.ready,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (previousWindow === undefined) {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
}
|
||||
else {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt > 5000) {
|
||||
throw new Error('Timed out waiting for scene readiness')
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for scene readiness')
|
||||
},
|
||||
locator(selector: string): FakeLocator {
|
||||
return {
|
||||
async evaluateAll<T>(callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) {
|
||||
const elements = getRootNames().map(name => ({
|
||||
getAttribute(nameToGet: string) {
|
||||
return nameToGet === 'data-scenario-capture-root' ? name : null
|
||||
},
|
||||
}))
|
||||
|
||||
return callback(elements)
|
||||
},
|
||||
async waitFor(opts: { state: 'visible' }) {
|
||||
if (opts.state !== 'visible' || !hasRoot(selector)) {
|
||||
throw new Error(`Selector not visible: ${selector}`)
|
||||
}
|
||||
},
|
||||
async screenshot(opts: { animations: 'disabled', path: string }) {
|
||||
await writeFile(opts.path, `fake screenshot for ${selector}\n`)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let captureBrowserRoots: typeof import('./capture').captureBrowserRoots
|
||||
|
||||
vi.mock('playwright', () => {
|
||||
return {
|
||||
chromium: {
|
||||
launch: vi.fn(async () => {
|
||||
let page: FakePage | undefined
|
||||
|
||||
return {
|
||||
newContext: async () => ({
|
||||
newPage: async () => {
|
||||
page = createFixturePage('')
|
||||
return page
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
close: async () => {},
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ captureBrowserRoots } = await import('./capture'))
|
||||
})
|
||||
|
||||
async function createViteSceneFixture(): Promise<string> {
|
||||
const rootDir = await mkdtemp(path.join(process.cwd(), '.tmp-scene-'))
|
||||
|
||||
await mkdir(path.join(rootDir, 'artifacts'), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
path.join(rootDir, 'index.html'),
|
||||
`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scene fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.__SCENARIO_CAPTURE_READY__ = true
|
||||
</script>
|
||||
<main style="display: grid; gap: 24px; padding: 24px; font-family: sans-serif;">
|
||||
<section data-scenario-capture-root="intro-chat-window" style="width: 480px; min-height: 160px; padding: 24px; border: 2px solid #334155; border-radius: 20px; background: #0f172a; color: white;">
|
||||
<h1 style="margin: 0 0 12px;">Chat window</h1>
|
||||
<p style="margin: 0;">Capture root fixture.</p>
|
||||
</section>
|
||||
<section data-scenario-capture-root="intro-websocket-settings" style="width: 480px; min-height: 160px; padding: 24px; border: 2px solid #334155; border-radius: 20px; background: #111827; color: white;">
|
||||
<h1 style="margin: 0 0 12px;">WebSocket settings</h1>
|
||||
<p style="margin: 0;">Capture root fixture.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
)
|
||||
|
||||
await writeFile(
|
||||
path.join(rootDir, 'vite.config.ts'),
|
||||
`import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: 'artifacts',
|
||||
})
|
||||
`,
|
||||
)
|
||||
|
||||
return rootDir
|
||||
}
|
||||
|
||||
describe('captureBrowserRoots', () => {
|
||||
it('captures all roots from a vite-served scene app', async () => {
|
||||
const sceneAppRoot = await createViteSceneFixture()
|
||||
const outputDir = path.join(sceneAppRoot, 'artifacts', 'final-test')
|
||||
|
||||
const artifacts = await captureBrowserRoots({
|
||||
sceneAppRoot,
|
||||
routePath: '/',
|
||||
outputDir,
|
||||
})
|
||||
|
||||
expect(artifacts.map(item => item.rootName)).toEqual([
|
||||
'intro-chat-window',
|
||||
'intro-websocket-settings',
|
||||
])
|
||||
expect(artifacts.every(item => item.filePath.startsWith(outputDir))).toBe(true)
|
||||
}, 120000)
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function captureRootSelector(rootName: string): string {
|
||||
return `[data-scenario-capture-root=${JSON.stringify(rootName)}]`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface CaptureBrowserCliArguments {
|
||||
renderEntry: string
|
||||
outputDir: string
|
||||
rootNames: string[]
|
||||
}
|
||||
|
||||
export interface BrowserCaptureRequest {
|
||||
sceneAppRoot?: string
|
||||
baseUrl?: string
|
||||
routePath: string
|
||||
outputDir: string
|
||||
rootNames?: string[]
|
||||
viewport?: {
|
||||
width: number
|
||||
height: number
|
||||
deviceScaleFactor?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CapturedRootArtifact {
|
||||
rootName: string
|
||||
filePath: string
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import type { ViteDevServer } from 'vite'
|
||||
|
||||
import { createServer } from 'vite'
|
||||
|
||||
export interface ManagedViteServer {
|
||||
baseUrl: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
function resolveViteServerUrl(server: ViteDevServer, sceneAppRoot: string): string {
|
||||
const url = server.resolvedUrls?.local?.[0] ?? server.resolvedUrls?.network?.[0]
|
||||
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
|
||||
const address = server.httpServer?.address()
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error(`Unable to determine Vite dev server address for ${sceneAppRoot}`)
|
||||
}
|
||||
|
||||
const { address: host, port } = address as AddressInfo
|
||||
|
||||
return `http://${host}:${port}`
|
||||
}
|
||||
|
||||
export async function startSceneViteServer(sceneAppRoot: string): Promise<ManagedViteServer> {
|
||||
const server = await createServer({
|
||||
root: sceneAppRoot,
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 41731,
|
||||
strictPort: false,
|
||||
},
|
||||
})
|
||||
|
||||
await server.listen()
|
||||
|
||||
return {
|
||||
baseUrl: resolveViteServerUrl(server, sceneAppRoot),
|
||||
close: async () => {
|
||||
await server.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
+22
-26
@@ -1,62 +1,57 @@
|
||||
# DevTool - Capture Stage Tamagotchi
|
||||
# Vishot Runner - Electron
|
||||
|
||||
Capture screenshots from the built `stage-tamagotchi` Electron app with TypeScript scenarios.
|
||||
Capture raw screenshots from the built `stage-tamagotchi` Electron app with TypeScript scenarios.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package now provides three things:
|
||||
This package is the Electron capture runner. It provides:
|
||||
|
||||
- a runtime surface in `src/index.ts`
|
||||
- the `capture` CLI in `src/cli/capture.ts`
|
||||
- the `defineScenario()` authoring helper for scenario modules
|
||||
- reusable helpers for the controls island, settings window, dialogs, drawers, and stage windows
|
||||
|
||||
Legacy POC-only files are gone from the package surface. The package is now focused on capture/runtime behavior rather than Playwright test-runner scaffolding or docs generation.
|
||||
This package stops at raw business screenshots. It does not own the scenario modules themselves; those live in `@proj-airi/scenarios-stage-tamagotchi-electron`.
|
||||
|
||||
## Usage
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/stage-tamagotchi build
|
||||
pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run
|
||||
pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir packages/scenarios-stage-tamagotchi-browser/artifacts/raw
|
||||
```
|
||||
|
||||
## Demo Walkthrough
|
||||
This writes the raw inputs consumed by the browser scene package:
|
||||
|
||||
This package also ships a demo scenario that captures these states in order:
|
||||
- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-controls-island-expanded.png`
|
||||
- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-settings-window.png`
|
||||
- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-chat-window.png`
|
||||
- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/03-websocket-settings.png`
|
||||
|
||||
- `00-controls-island-expanded`
|
||||
- `01-settings-window`
|
||||
- `02-chat-window`
|
||||
- `03-websocket-settings`
|
||||
|
||||
Run it from the repo root:
|
||||
Then export the composed browser assets:
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/stage-tamagotchi build
|
||||
pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ./artifacts/demo-run
|
||||
pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture
|
||||
```
|
||||
|
||||
Expected files:
|
||||
In this environment, the raw capture command worked end-to-end after running outside the sandbox because `tsx` pipe creation was denied inside the sandbox (`listen EPERM` on the temporary `tsx` IPC pipe).
|
||||
|
||||
- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/00-controls-island-expanded.png`
|
||||
- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/01-settings-window.png`
|
||||
- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/02-chat-window.png`
|
||||
- `packages/devtool-capture-stage-tamagotchi/artifacts/demo-run/03-websocket-settings.png`
|
||||
## Additional Examples
|
||||
|
||||
To verify the controls-island hearing button specifically:
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/stage-tamagotchi build
|
||||
pnpm -F @proj-airi/devtool-capture-stage-tamagotchi capture -- src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo
|
||||
pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo
|
||||
```
|
||||
|
||||
Expected file:
|
||||
|
||||
- `packages/devtool-capture-stage-tamagotchi/artifacts/hearing-demo/hearing-dialog.png`
|
||||
- `packages/vishot-runner-electron/artifacts/hearing-demo/hearing-dialog.png`
|
||||
|
||||
## Scenario Authoring
|
||||
|
||||
```ts
|
||||
import { defineScenario } from '@proj-airi/devtool-capture-stage-tamagotchi'
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'settings-connection',
|
||||
@@ -85,7 +80,7 @@ For surfaces built with `DialogRoot` or `DrawerRoot`, the runtime now exposes:
|
||||
Example:
|
||||
|
||||
```ts
|
||||
import { defineScenario } from '@proj-airi/devtool-capture-stage-tamagotchi'
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
export default defineScenario({
|
||||
id: 'dismiss-helpers',
|
||||
@@ -122,5 +117,6 @@ It does not open the settings window from the main window for you. The intended
|
||||
|
||||
## Notes
|
||||
|
||||
- Importing `@proj-airi/devtool-capture-stage-tamagotchi` now resolves to `src/index.ts` via the package export surface.
|
||||
- Importing `@proj-airi/vishot-runner-electron` resolves to `src/index.ts` via the package export surface.
|
||||
- The package is no longer a Playwright test suite package.
|
||||
- Final composed exports live in `packages/scenarios-stage-tamagotchi-browser/artifacts/final`, not this package.
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@proj-airi/devtool-capture-stage-tamagotchi",
|
||||
"name": "@proj-airi/vishot-runner-electron",
|
||||
"type": "module",
|
||||
"version": "0.9.0-beta.3",
|
||||
"private": true,
|
||||
"description": "Playwright-driven Electron capture tooling for stage-tamagotchi",
|
||||
"description": "Playwright-driven Electron capture runner for stage-tamagotchi",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
+9
-9
@@ -5,32 +5,32 @@ import { parseCaptureCliArguments } from './capture'
|
||||
describe('parseCaptureCliArguments', () => {
|
||||
it('accepts a scenario path with --output-dir', () => {
|
||||
expect(parseCaptureCliArguments([
|
||||
'src/scenarios/settings-connection.ts',
|
||||
'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
'--output-dir',
|
||||
'./artifacts/manual-run',
|
||||
])).toEqual({
|
||||
scenarioPath: 'src/scenarios/settings-connection.ts',
|
||||
scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
outputDir: './artifacts/manual-run',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the -o alias', () => {
|
||||
expect(parseCaptureCliArguments([
|
||||
'src/scenarios/settings-connection.ts',
|
||||
'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
'-o',
|
||||
'./artifacts/manual-run',
|
||||
])).toEqual({
|
||||
scenarioPath: 'src/scenarios/settings-connection.ts',
|
||||
scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
outputDir: './artifacts/manual-run',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts --output-dir=value', () => {
|
||||
expect(parseCaptureCliArguments([
|
||||
'src/scenarios/settings-connection.ts',
|
||||
'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
'--output-dir=./artifacts/manual-run',
|
||||
])).toEqual({
|
||||
scenarioPath: 'src/scenarios/settings-connection.ts',
|
||||
scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
outputDir: './artifacts/manual-run',
|
||||
})
|
||||
})
|
||||
@@ -44,14 +44,14 @@ describe('parseCaptureCliArguments', () => {
|
||||
|
||||
it('rejects missing output directory', () => {
|
||||
expect(() => parseCaptureCliArguments([
|
||||
'src/scenarios/settings-connection.ts',
|
||||
'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
])).toThrow('Usage: capture <scenario.ts> --output-dir <dir>')
|
||||
})
|
||||
|
||||
it('rejects extra positional arguments', () => {
|
||||
expect(() => parseCaptureCliArguments([
|
||||
'src/scenarios/settings-connection.ts',
|
||||
'src/scenarios/demo-hearing-dialog.ts',
|
||||
'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts',
|
||||
'packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts',
|
||||
'--output-dir',
|
||||
'./artifacts/manual-run',
|
||||
])).toThrow('Usage: capture <scenario.ts> --output-dir <dir>')
|
||||
+2
-2
@@ -28,8 +28,8 @@ const captureHelpText = `
|
||||
--output-dir, -o Directory to write PNG screenshots into
|
||||
|
||||
Examples
|
||||
$ capture src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run
|
||||
$ capture src/scenarios/settings-connection.ts -o ./artifacts/manual-run
|
||||
$ capture packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run
|
||||
$ capture packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts -o ./artifacts/manual-run
|
||||
`
|
||||
|
||||
const captureUsageMessage = 'Usage: capture <scenario.ts> --output-dir <dir>'
|
||||
@@ -0,0 +1,17 @@
|
||||
export { capturePage } from './runtime/capture'
|
||||
export { createScenarioContext } from './runtime/context'
|
||||
export { defineScenario } from './runtime/define-scenario'
|
||||
export { loadScenarioModule } from './runtime/load-scenario'
|
||||
export type { LoadedScenarioModule } from './runtime/load-scenario'
|
||||
export type {
|
||||
CaptureOptions,
|
||||
ControlsIslandApi,
|
||||
DialogsApi,
|
||||
DrawersApi,
|
||||
ElectronScenario,
|
||||
ScenarioContext,
|
||||
SettingsWindowApi,
|
||||
StageWindowsApi,
|
||||
} from './runtime/types'
|
||||
export { resolveElectronAppInfo } from './utils/app-path'
|
||||
export type { ElectronAppInfo } from './utils/app-path'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ElectronScenario } from './types'
|
||||
|
||||
export function defineScenario(scenario: ElectronScenario): ElectronScenario {
|
||||
return scenario
|
||||
}
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import type { StageTamagotchiScenario } from './types'
|
||||
import type { ElectronScenario } from './types'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
@@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url'
|
||||
|
||||
export interface LoadedScenarioModule {
|
||||
modulePath: string
|
||||
scenario: StageTamagotchiScenario
|
||||
scenario: ElectronScenario
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
@@ -19,12 +19,12 @@ function getErrorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function isStageTamagotchiScenario(value: unknown): value is StageTamagotchiScenario {
|
||||
function isElectronScenario(value: unknown): value is ElectronScenario {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scenario = value as Partial<StageTamagotchiScenario>
|
||||
const scenario = value as Partial<ElectronScenario>
|
||||
return typeof scenario.id === 'string' && typeof scenario.run === 'function'
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function loadScenarioModule(scenarioPath: string): Promise<LoadedSc
|
||||
throw new Error(`Failed to load scenario module at ${modulePath}: ${getErrorMessage(error)}`)
|
||||
}
|
||||
|
||||
if (!isStageTamagotchiScenario(moduleNamespace.default)) {
|
||||
if (!isElectronScenario(moduleNamespace.default)) {
|
||||
const exportedKeys = Object.keys(moduleNamespace).join(', ') || '(none)'
|
||||
throw new Error(
|
||||
`Scenario module at ${modulePath} must export a default scenario object with id and run(ctx). Exported keys: ${exportedKeys}`,
|
||||
+1
-1
@@ -42,7 +42,7 @@ export interface ScenarioContext {
|
||||
drawers: DrawersApi
|
||||
}
|
||||
|
||||
export interface StageTamagotchiScenario {
|
||||
export interface ElectronScenario {
|
||||
id: string
|
||||
run: (context: ScenarioContext) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
# Vishot Runtime
|
||||
|
||||
Shared runtime contracts and Vue bindings for Vishot scene capture.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package owns the browser-side runtime surface used by scene packages. It provides:
|
||||
|
||||
- readiness helpers via `markScenarioReady()` and `resetScenarioReady()`
|
||||
- the shared `ScenarioCaptureRootProps` type
|
||||
- framework-specific bindings through subpath exports such as `@proj-airi/vishot-runtime/vue`
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { markScenarioReady, resetScenarioReady } from '@proj-airi/vishot-runtime'
|
||||
import { ScenarioCanvas, ScenarioCaptureRoot } from '@proj-airi/vishot-runtime/vue'
|
||||
```
|
||||
|
||||
Keep framework-agnostic contracts at the package root and import Vue SFC bindings from `./vue`.
|
||||
|
||||
```ts
|
||||
import {
|
||||
markScenarioReady,
|
||||
resetScenarioReady,
|
||||
} from '@proj-airi/vishot-runtime'
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user