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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 75 additions & 75 deletions FEATURES.md

Large diffs are not rendered by default.

148 changes: 74 additions & 74 deletions docs/feature-status.json

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions scripts/capture/record.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
*
* Needs an ffmpeg with x11grab (set ORBIT_FFMPEG to its path) and a display:
* xvfb-run -a -s "-screen 0 1440x900x24" node scripts/capture/record.mjs <scenario> [--out clips/<scenario>.mp4] [--shots dir]
* [--fps 30] [--port 3380]
*
* --fps sets the recording's frame rate (60 to judge motion frame by frame). --port is where the fixture
* server starts looking for a free port, up to 9 above it, so a capture stays inside its own range.
*
* Scenarios live in scripts/capture/scenarios/<name>.mjs and export
* `default async function (t)`; see helpers in `makeToolkit` below.
Expand All @@ -25,13 +29,17 @@ const opt = (name, fallback) => {
return i >= 0 ? argv[i + 1] : fallback
}
if (!scenario) {
console.error('usage: record.mjs <scenario> [--out file.mp4] [--shots dir] [--size 1440x900]')
console.error(
'usage: record.mjs <scenario> [--out file.mp4] [--shots dir] [--size 1440x900] [--fps 30] [--port 3380]',
)
process.exit(2)
}
const scenarioName = basename(scenario, '.mjs')
const OUT = resolve(opt('out', `clips/${scenarioName}.mp4`))
const SHOTS = opt('shots') ? resolve(opt('shots')) : undefined
const [W, H] = opt('size', '1440x900').split('x').map(Number)
const FPS = opt('fps', '30')
const PORT = Number(opt('port', '3380'))
const FFMPEG = process.env.ORBIT_FFMPEG ?? 'ffmpeg'
const DISPLAY = process.env.DISPLAY
if (!DISPLAY) throw new Error('No DISPLAY: run under xvfb-run')
Expand Down Expand Up @@ -85,10 +93,10 @@ main{padding:40px;max-width:1100px}h1{font-size:40px;margin:8px 0 6px;letter-spa
</style></head><body><header><div class="logo"></div><b>${esc(name)}</b><nav><span>Home</span><span>Library</span><span>Settings</span></nav></header>
<main><h1>${esc(name)}</h1><p class="lead">A local demo page for Orbit screen captures, served from 127.0.0.1.</p><div class="grid">${cards}</div></main></body></html>`)
})
let port = 3380
let port = PORT
await new Promise((ok, fail) => {
server.on('error', (err) =>
err.code === 'EADDRINUSE' && port < 3399 ? server.listen(++port, '127.0.0.1') : fail(err),
err.code === 'EADDRINUSE' && port < Math.min(PORT + 9, 3399) ? server.listen(++port, '127.0.0.1') : fail(err),
)
server.listen(port, '127.0.0.1', ok)
})
Expand All @@ -108,7 +116,7 @@ function startRecorder(file) {
'-draw_mouse',
'1',
'-framerate',
'30',
FPS,
'-video_size',
`${W}x${H}`,
'-i',
Expand Down
75 changes: 75 additions & 0 deletions scripts/capture/scenarios/cb.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* The command bar (CB-02, CB-03, CB-09, CB-10, CB-13, CB-22; FL-16, FL-17), about 12 s, for the "feels right"
* sign-off (D48). ⌘T opens the bar; typing narrows the results as each key lands; ↓ moves the
* highlight; Enter switches to an open tab; ⌘L edits the current page's address; Escape closes;
* an action runs by name.
*
* Each step prints a `[mark] <name> <ms>` line on stderr: its time since the scenario began.
* record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip.
*/

let t0 = 0
const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`)

/** Unrecorded: a Space with a few open tabs, each visited once so it has its title and icon. */
export async function setup(t) {
const s = await t.state()
const win = Object.values(s.windows).find((w) => w.kind === 'main')
const tabs = []
for (const name of ['Recipes', 'Reading list', 'Budget', 'Trip planning', 'Team wiki']) {
tabs.push(await t.run('tabs.create', { url: t.fx.url(name), spaceId: win.spaceId, activate: false }))
}
for (const tabId of tabs) {
await t.run('tabs.activate', { tabId })
await t.sleep(450)
}
}

export default async function (t) {
t0 = performance.now()
await t.sleep(600)

// CB-02, CB-22, FL-16: ⌘T shows the bar in the first frame, fading and scaling in.
mark('open')
await t.key('Mod+T')
let overlay = await t.overlay()
await t.sleep(700)

// CB-22, FL-17: each key is echoed in its frame, and the results follow; CB-10: ↓ and ↑ move the highlight.
mark('type')
await t.type(overlay, 'rec', 110)
await t.sleep(700)
mark('down')
await overlay.keyboard.press('ArrowDown')
await t.sleep(500)
await overlay.keyboard.press('ArrowUp')
await t.sleep(600)

// CB-09: Enter on an open tab switches to it; the bar hides at once.
mark('enter')
await overlay.keyboard.press('Enter')
await t.sleep(1400)

// CB-03: ⌘L opens the bar on the current page's address, selected.
mark('cmd-l')
await t.key('Mod+L')
overlay = await t.overlay()
await t.sleep(1200)

// FL-16: Escape fades and shrinks it out.
mark('escape')
await overlay.keyboard.press('Escape')
await t.sleep(1200)

// CB-13: an action by name.
mark('action')
await t.key('Mod+T')
overlay = await t.overlay()
await t.sleep(400)
await t.type(overlay, 'new spa', 110)
await t.sleep(1100)
mark('action-escape')
await overlay.keyboard.press('Escape')
await t.sleep(1200)
mark('end')
}
134 changes: 134 additions & 0 deletions scripts/capture/scenarios/md.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Picture-in-picture (MD-01, MD-03, MD-04, MD-07, MD-16; FL-32), about 14 s, for the "feels right"
* sign-off (D48). Leaving a tab whose video plays moves the video into the floating player at the
* bottom-right; the pointer over it shows its controls; selecting the tab again puts the video back in
* the page, still playing; "Back to tab" plays the reverse motion.
*
* Each step prints a `[mark] <name> <ms>` line on stderr: its time since the scenario began.
* record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip.
*/
import { execFile } from 'node:child_process'

let t0 = 0
const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`)

/** A playing video drawn from a canvas (a colour that changes every 40 ms), with a quiet tone: no file, no network. */
const VIDEO = `(() => {
const box = document.createElement('div')
box.style.cssText = 'margin:0 0 28px'
box.innerHTML = '<video id="v" width="640" height="360" playsinline style="border-radius:12px;background:#000"></video><canvas id="c" width="640" height="360" style="position:absolute;left:-9999px"></canvas>'
document.querySelector('.lead').after(box)
const c = document.getElementById('c'), g = c.getContext('2d'); let n = 0
setInterval(() => {
g.fillStyle = 'hsl(' + (n * 3 % 360) + ',65%,52%)'; g.fillRect(0, 0, 640, 360)
g.fillStyle = '#fff'; g.font = 'bold 64px system-ui'; g.fillText(String(n++), 40, 300)
}, 40)
const stream = c.captureStream(25)
const ac = new AudioContext(), osc = ac.createOscillator(), gain = ac.createGain(); gain.gain.value = 0.03
const dest = ac.createMediaStreamDestination(); osc.connect(gain).connect(dest); osc.start()
stream.addTrack(dest.stream.getAudioTracks()[0])
const v = document.getElementById('v'); v.srcObject = stream
return v.play().then(() => 'playing')
})()`

/** Moves the on-screen pointer (display coordinates) in eased steps. */
let at = null
async function pointerTo(x, y, steps = 16) {
const from = at ?? { x, y }
for (let i = 1; i <= steps; i++) {
const k = 0.5 - 0.5 * Math.cos((Math.PI * i) / steps)
await new Promise((r) =>
execFile(
'xdotool',
['mousemove', String(Math.round(from.x + (x - from.x) * k)), String(Math.round(from.y + (y - from.y) * k))],
() => r(),
),
)
}
at = { x, y }
}

/** The floating player's window bounds once it shows a video and has settled, or null while it isn't showing. */
const player = (t) =>
t.app.evaluate(() => {
const info = globalThis.__orbitMedia.manager.player.info()
return info.visible && info.holdsView ? info.bounds : null
})

async function waitFor(t, test, ms = 8000) {
const end = Date.now() + ms
while (Date.now() < end) {
const v = await test()
if (v) return v
await t.sleep(40)
}
throw new Error('timed out')
}

let video = ''
let notes = ''

/**
* Unrecorded: a tab whose video plays, and a second tab to leave it for. The main window leaves the
* screen's bottom-right corner free: a display with no window manager keeps no window on top, so the
* player shows only where no other window covers it.
*/
export async function setup(t) {
const s = await t.state()
const win = Object.values(s.windows).find((w) => w.kind === 'main')
// From a task of its own, as record.mjs resizes: the inspector runs an evaluate by interrupt, and Orbit's
// resize handler would run part-way through other main-process code.
await t.app.evaluate(async (_e, id) => {
await new Promise((r) => setImmediate(r))
globalThis.__orbit.ctx.windows.get(id)?.setBounds({ x: 0, y: 0, width: 1000, height: 640 })
}, win.id)
await t.sleep(400)
notes = await t.run('tabs.create', { url: t.fx.url('Notes'), spaceId: win.spaceId, activate: false })
video = await t.run('tabs.create', { url: t.fx.url('Film club'), spaceId: win.spaceId, activate: false })
await t.run('tabs.activate', { tabId: notes })
await t.sleep(450)
await t.run('tabs.activate', { tabId: video })
await t.sleep(900)
// As if the person pressed play: the script runs with a user gesture, so the sound may start.
const state = await t.app.evaluate(
(_e, [id, code]) => globalThis.__orbit.ctx.tabs.get(id).webContents.executeJavaScript(code, true),
[video, VIDEO],
)
if (state !== 'playing') throw new Error(`the video didn't start: ${state}`)
await t.sleep(1200)
}

export default async function (t) {
t0 = performance.now()
await t.sleep(600)

// MD-01, MD-16: leaving the tab moves its playing video into the floating player, bottom-right.
mark('leave')
await t.run('tabs.activate', { tabId: notes })
const box = await waitFor(t, () => player(t))
await t.sleep(1800)

// MD-04: the pointer over the player shows its controls.
mark('hover')
await pointerTo(box.x + box.width / 2, box.y + box.height / 2, 20)
await t.sleep(1400)
await pointerTo(box.x - 260, box.y - 220, 16)
await t.sleep(600)

// MD-01: selecting the tab again puts the video back in the page, still playing, without a blank frame.
mark('back')
await t.run('tabs.activate', { tabId: video })
await waitFor(t, async () => !(await player(t)))
await t.sleep(1500)

// MD-07, MD-16: "Back to tab" from the player plays the reverse motion toward the page.
mark('leave-2')
await t.run('tabs.activate', { tabId: notes })
await waitFor(t, () => player(t))
await t.sleep(1500)
mark('pip-back')
await t.run('media.pipBack', {})
await waitFor(t, async () => !(await player(t)))
await t.sleep(1500)
mark('end')
}
115 changes: 115 additions & 0 deletions scripts/capture/scenarios/mw.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* The Mini Window (MW-02, MW-04 to MW-06, MW-09, MW-15), about 16 s, for the "feels right" sign-off
* (D48). A link from another app opens a Mini Window with a quick scale-and-fade; "Open in…" moves its
* page into the main window, where its row slides into the sidebar; ⌘⌥N opens a blank one whose lookup
* bar takes the keys typed at once; closing it fades it out.
*
* Each step prints a `[mark] <name> <ms>` line on stderr: its time since the scenario began.
* record.mjs begins it 0.7 s after the recording starts, so a step shows about 0.7 s later in the clip.
*/

let t0 = 0
const mark = (name) => console.error(`[mark] ${name} ${Math.round(performance.now() - t0)}`)

/** The Mini Windows open now, with whether each one shows. */
const minis = (t) =>
t.app.evaluate(({ BrowserWindow }) => {
const ctx = globalThis.__orbit.ctx
return Object.values(ctx.store.state.windows)
.filter((w) => w.kind === 'mini')
.map((w) => {
const win = BrowserWindow.getAllWindows().find(
(b) => !b.isDestroyed() && ctx.windows.isUi(b.webContents) && ctx.windows.windowIdFor(b.webContents) === w.id,
)
return { id: w.id, visible: !!win?.isVisible() }
})
})

async function waitFor(t, test, ms = 8000) {
const end = Date.now() + ms
while (Date.now() < end) {
const v = await test()
if (v) return v
await t.sleep(40)
}
throw new Error('timed out')
}

/** A Mini Window's own UI page (its top bar and lookup bar). */
async function miniUi(t, windowId) {
return waitFor(t, async () => {
for (const p of t.app.windows()) {
if (!p.url().includes('view=mini')) continue
const id = await p.evaluate(() => window.orbit.windowId).catch(() => '')
if (id === windowId) return p
}
return null
})
}

/** Runs a command from a Mini Window's UI, as its buttons do, without waiting on it (it may close the window). */
async function fromMini(t, windowId, name, args) {
const ui = await miniUi(t, windowId)
await ui.evaluate(([n, a]) => void window.orbit.invoke(n, a), [name, args]).catch(() => {})
}

let spaceId = ''

/** Unrecorded: two tabs in the main window, and a blank Mini Window kept warm as Orbit does after launch. */
export async function setup(t) {
const s = await t.state()
const win = Object.values(s.windows).find((w) => w.kind === 'main')
spaceId = win.spaceId
const tabs = []
for (const name of ['Inbox', 'Calendar']) {
tabs.push(await t.run('tabs.create', { url: t.fx.url(name), spaceId, activate: false }))
}
for (const tabId of tabs) {
await t.run('tabs.activate', { tabId })
await t.sleep(450)
}
await t.run('mini.prepare', {})
await t.sleep(1500)
}

export default async function (t) {
t0 = performance.now()
await t.sleep(600)

// MW-02, MW-04: a link from another app opens a Mini Window with a quick scale-and-fade.
mark('open-link')
const shared = t.fx.url('Shared doc').replace('127.0.0.1', 'localhost')
await t.app.evaluate(({ app }, u) => app.emit('open-url', { preventDefault() {} }, u), shared)
const [first] = await waitFor(t, async () => {
const list = await minis(t)
return list.length === 1 && list[0].visible ? list : null
})
await t.sleep(1800)

// MW-05, MW-15: "Open in…" moves the page into the main window; its row slides into the sidebar.
mark('open-in')
await fromMini(t, first.id, 'mini.moveToSpace', { spaceId })
await waitFor(t, async () => (await minis(t)).length === 0)
await t.sleep(1500)

// MW-06, MW-15: ⌘⌥N opens a blank Mini Window; every key typed at once lands in its lookup bar.
mark('blank')
await t.key('Mod+Alt+N')
const [blank] = await waitFor(t, async () => {
const list = await minis(t)
return list.length === 1 ? list : null
})
const ui = await miniUi(t, blank.id)
await t.type(ui, t.fx.url('Lookup').replace('http://', ''), 45)
await t.sleep(400)
mark('lookup-enter')
await ui.keyboard.press('Enter')
await t.sleep(1800)

// MW-09, MW-15: closing fades the window out.
mark('close')
await fromMini(t, blank.id, 'mini.control', { op: 'close' })
await waitFor(t, async () => (await minis(t)).every((m) => !m.visible))
await t.sleep(1200)
mark('end')
}
Loading