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
16 changes: 14 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,22 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci --no-audit --no-fund
- name: Validate documentation
run: npm run check:docs
- name: Build and upload site
uses: withastro/action@v6
- run: npm run test:archives
- run: npm run build
- name: Assemble frozen release manuals
run: npm run assemble:archives
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
needs: build
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,21 @@ npm run check
Small corrections can be made with the **Edit page** link on the published
site. For larger changes, read [CONTRIBUTING.md](CONTRIBUTING.md) and open a
pull request.

## Manuals shipped with Vizard

`npm run build:release -- vX.Y.Z` builds a complete manual at
`/vizard-docs/releases/vX.Y.Z/`, with release identity, local search and a hashed
file inventory in `dist/manifest.json`. The app build captures newest docs main
once and keeps the generated bundle for every platform and release retry.

Current docs continue deploying on main pushes. App releases upload the already
built `manual.zip` and `manual.zip.json` to matching docs releases. Deployment
runs `npm run assemble:archives` after building current docs, copying those
verified files unchanged into `dist/releases`. Never rebuild/restyle old manuals
or replace published release tags/assets. The `/versions/` page links to current
and archived manuals; links from installed manuals are marked online.

Run `npm run test:archives` to check preservation and failure behavior. To deploy
current docs explicitly: `gh workflow run deploy.yml --repo plmn95/vizard-docs`.
The app repository owns publication credentials and the build/release procedure.
3 changes: 2 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const isVercel = process.env.VERCEL === '1';
// https://astro.build/config
export default defineConfig({
site: isVercel ? 'https://vizard-docs.vercel.app' : 'https://plmn95.github.io',
base: isVercel ? '/' : '/vizard-docs',
base: process.env.VIZARD_DOCS_BASE || (isVercel ? '/' : '/vizard-docs'),
integrations: [
starlight({
title: 'Vizard Documentation',
Expand All @@ -18,6 +18,7 @@ export default defineConfig({
],
components: {
SiteTitle: './src/components/SiteTitle.astro',
Footer: './src/components/ManualFooter.astro',
},
editLink: {
baseUrl: 'https://github.com/plmn95/vizard-docs/edit/main/',
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"check": "npm run check:docs && npm run build",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"build:release": "node scripts/build-release.mjs",
"assemble:archives": "python3 scripts/assemble-archives.py",
"test:archives": "python3 scripts/test-archives.py"
},
"dependencies": {
"@astrojs/starlight": "^0.41.10",
Expand Down
65 changes: 65 additions & 0 deletions scripts/assemble-archives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Assemble frozen release assets after building the current site."""
import hashlib
import html
import json
from pathlib import Path, PurePosixPath
import re
import subprocess
import tempfile
import zipfile

repo = 'plmn95/vizard-docs'
root = Path('dist')

def gh(*args):
return subprocess.check_output(['gh', *args], text=True)

def sha(data):
return hashlib.sha256(data).hexdigest()

pages = json.loads(gh('api', '--paginate', '--slurp', f'repos/{repo}/releases?per_page=100'))
releases = [r for page in pages for r in page if not r['draft']]
links = []
for release in releases:
version = release['tag_name']
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?', version):
continue
names = {a['name'] for a in release['assets']}
if not {'manual.zip', 'manual.zip.json'} <= names:
raise RuntimeError(f'Release {version} is missing its manual. Refusing to remove it from the site.')
with tempfile.TemporaryDirectory() as tmp:
for name in ('manual.zip', 'manual.zip.json'):
gh('release', 'download', version, '--repo', repo, '--pattern', name, '--dir', tmp)
archive = Path(tmp, 'manual.zip')
record = json.loads(Path(tmp, 'manual.zip.json').read_text())
if sha(archive.read_bytes()) != record['sha256'] or record['appVersion'] != version:
raise RuntimeError(f'Archive checksum/identity mismatch: {version}')
destination = root / 'releases' / version
if destination.exists():
raise RuntimeError(f'Archive path already exists: {destination}; build a clean site first')
with zipfile.ZipFile(archive) as z:
seen = set()
for entry in z.infolist():
name = entry.filename
if name in seen or name.startswith('/') or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or ':' in name or (entry.external_attr >> 16) & 0o170000 == 0o120000:
raise RuntimeError('Unsafe archive entry')
seen.add(name)
data = z.read('manifest.json')
manifest = json.loads(data)
if sha(data) != record['manifestSha256'] or manifest['docsCommit'] != record['docsCommit'] or manifest['appVersion'] != version or manifest.get('localPreview') or manifest['basePath'] != f'/vizard-docs/releases/{version}/':
raise RuntimeError(f'Manifest mismatch: {version}')
if seen != set(manifest['files']) | {'manifest.json'}:
raise RuntimeError('Archive file inventory mismatch')
for name, expected in manifest['files'].items():
if sha(z.read(name)) != expected:
raise RuntimeError('File checksum mismatch: ' + name)
z.extractall(destination)
links.append(f'<li><a href="../releases/{version}/">Vizard {html.escape(version)}</a></li>')
chooser = root / 'versions'
chooser.mkdir(exist_ok=True)
(chooser / 'index.html').write_text('<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vizard documentation versions</title><style>body{font:1rem/1.6 system-ui;max-width:44rem;margin:4rem auto;padding:0 1.5rem;background:#141517;color:#eceef2}a{color:#c0d7ff}li{margin:1rem 0}</style><main><h1>Documentation versions</h1><p>Each release keeps the manual and appearance shipped with the app.</p><ul><li><a href="../">Current documentation</a></li>' + ''.join(links) + '</ul></main></html>')
size = sum(p.stat().st_size for p in root.rglob('*') if p.is_file())
print(f'Published site: {size:,} bytes; {len(links)} frozen manuals')
if size > 950_000_000:
raise RuntimeError('Site is approaching the GitHub Pages 1 GB limit; change archive hosting before deploying')
34 changes: 34 additions & 0 deletions scripts/build-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, writeFileSync, lstatSync } from 'node:fs';
import { resolve, relative } from 'node:path';

const [version, base = `/vizard-docs/releases/${version}/`] = process.argv.slice(2);
if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(version || ''))
throw new Error('Usage: npm run build:release -- vX.Y.Z[-prerelease] [basePath]');
if (!/^\/(?:[A-Za-z0-9._-]+\/)+$/.test(base) || base.split('/').includes('..'))
throw new Error('basePath must be a safe absolute URL directory ending in /');
const commit = process.env.VIZARD_DOCS_COMMIT || execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Invalid documentation commit');
execFileSync(process.execPath, ['scripts/check-docs.mjs'], { stdio: 'inherit' });
execFileSync(process.execPath, ['node_modules/astro/bin/astro.mjs', 'build'], {
stdio: 'inherit', env: { ...process.env, VIZARD_DOCS_VERSION: version, VIZARD_DOCS_BASE: base },
});
const root = resolve('dist');
const files = {};
function visit(dir) {
for (const name of readdirSync(dir).sort()) {
const path = resolve(dir, name), stat = lstatSync(path);
if (stat.isSymbolicLink()) throw new Error(`Symlinks cannot ship: ${path}`);
if (stat.isDirectory()) visit(path);
else files[relative(root, path).replaceAll('\\', '/')] = createHash('sha256').update(readFileSync(path)).digest('hex');
}
}
visit(root);
if (!files['index.html'] || !Object.keys(files).some(p => p.startsWith('pagefind/wasm.') && p.endsWith('.pagefind')))
throw new Error('Missing manual entry or search engine');
writeFileSync(resolve(root, 'manifest.json'), JSON.stringify({
schemaVersion: 1, appVersion: version, docsCommit: commit, basePath: base,
entryPage: 'index.html', localPreview: process.env.VIZARD_DOCS_PREVIEW === '1', files,
}, null, 2) + '\n');
console.log(`Release manual: ${version}, documentation ${commit}, ${Object.keys(files).length} files`);
60 changes: 60 additions & 0 deletions scripts/test-archives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import hashlib
import json
import os
from pathlib import Path
import runpy
import tempfile
import unittest
from unittest.mock import patch
import zipfile

SCRIPT = Path(__file__).with_name('assemble-archives.py')

class ArchiveTest(unittest.TestCase):
def test_preserves_two_versions_and_rejects_missing_or_changed_assets(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
assets = {}
releases = []
for version, style in [('v1.0.0', 'old style'), ('v2.0.0', 'new style')]:
files = {'index.html': style.encode()}
manifest = json.dumps({'schemaVersion': 1, 'appVersion': version, 'docsCommit': 'a'*40,
'basePath': f'/vizard-docs/releases/{version}/', 'entryPage': 'index.html', 'localPreview': False,
'files': {p: hashlib.sha256(b).hexdigest() for p,b in files.items()}}).encode()
archive = root / (version + '.zip')
with zipfile.ZipFile(archive, 'w') as z:
z.writestr('index.html', style)
z.writestr('manifest.json', manifest)
assets[version] = {'manual.zip': archive.read_bytes(), 'manual.zip.json': json.dumps({
'appVersion': version, 'docsCommit': 'a'*40,
'sha256': hashlib.sha256(archive.read_bytes()).hexdigest(),
'manifestSha256': hashlib.sha256(manifest).hexdigest()}).encode()}
releases.append({'draft': False, 'tag_name': version, 'assets': [{'name': n} for n in assets[version]]})
def gh(args, **kwargs):
if args[1] == 'api': return json.dumps([releases])
version = args[3]
name = args[args.index('--pattern') + 1]
directory = Path(args[args.index('--dir') + 1])
(directory / name).write_bytes(assets[version][name])
return ''
cwd = Path.cwd()
try:
os.chdir(root)
Path('dist').mkdir()
with patch('subprocess.check_output', side_effect=gh): runpy.run_path(str(SCRIPT))
self.assertEqual(Path('dist/releases/v1.0.0/index.html').read_text(), 'old style')
self.assertEqual(Path('dist/releases/v2.0.0/index.html').read_text(), 'new style')
chooser = Path('dist/versions/index.html').read_text()
self.assertIn('v1.0.0', chooser); self.assertIn('v2.0.0', chooser)
import shutil
shutil.rmtree('dist'); Path('dist').mkdir()
releases[0]['assets'] = []
with patch('subprocess.check_output', side_effect=gh), self.assertRaisesRegex(RuntimeError, 'missing'):
runpy.run_path(str(SCRIPT))
releases[0]['assets'] = [{'name': n} for n in assets['v1.0.0']]
assets['v1.0.0']['manual.zip'] += b'tampered'
with patch('subprocess.check_output', side_effect=gh), self.assertRaisesRegex(RuntimeError, 'checksum'):
runpy.run_path(str(SCRIPT))
finally: os.chdir(cwd)

if __name__ == '__main__': unittest.main()
13 changes: 13 additions & 0 deletions src/components/ManualFooter.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
import Footer from '@astrojs/starlight/components/Footer.astro';
const version = process.env.VIZARD_DOCS_VERSION;
---
<div class="manual-links" data-pagefind-ignore>
{version && <p>Manual for Vizard {version}. Corrections are made in the latest documentation; check the current source before editing.</p>}
<a href="https://plmn95.github.io/vizard-docs/versions/">Documentation versions (online)</a>
</div>
<Footer><slot /></Footer>
<style>
.manual-links { margin-block: 2rem 1rem; font-size: var(--sl-text-sm); color: var(--sl-color-gray-2); }
p { margin-bottom: 0.5rem; }
</style>
2 changes: 1 addition & 1 deletion src/components/SiteTitle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { siteTitleHref } = Astro.locals.starlightRoute;

<a href={siteTitleHref} class="vizard-site-title" aria-label="Vizard Documentation home">
<span class="vizard-wordmark" translate="no">VIZARD</span>
<span class="vizard-site-label">Documentation</span>
<span class="vizard-site-label">Documentation{process.env.VIZARD_DOCS_VERSION ? ` · ${process.env.VIZARD_DOCS_VERSION}` : ''}</span>
</a>

<style>
Expand Down
6 changes: 5 additions & 1 deletion src/content/docs/reference/modulation/macro.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ In the main editing view, a patch starts with one macro and grows the bank
from its own add button; nothing caps how many a patch can hold. Each
macro carries its own name and its own MIDI CC binding.

A macro's own value is itself a modulation target: assign an LFO, another
macro, or any other source to it like any other parameter, including
self- or cross-macro modulation.

Mixer Mode has a separate, fixed set: 4 macro knobs per deck, each with its
own independent MIDI CC binding. These are scoped to their deck rather than
to the patch, and don't grow.
to the patch, don't grow, and aren't modulation targets.

Related: [Modulation Matrix](../../../concepts/modulation-matrix/),
[Mixer Mode](../../../concepts/mixer-mode/).
8 changes: 6 additions & 2 deletions src/content/docs/reference/modules/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ redrawing from a sample ring.
|---|---|---|
| X Source / Y Source | Selector | Manual, Audio L, Audio R, Generator A, or Generator B, independently per axis. |
| X / Y | Trough | Manual axis value; only live when its Source is Manual. |
| X Scale / Y Scale | Trough | Replaces X / Y for an axis whose Source is Audio or Generator. Gain on that axis's raw bipolar signal, 0 to 2, default 1. Corrects trace geometry on a non-square canvas, since the raw signal otherwise maps 1:1 to screen space with no gain. |
| Blend / Mix | Selector, Trough | Same blend-mode family every other module uses (Replace, Add, etc.) plus a 0-1 Mix amount, applied to the composited trace+backdrop result. |
| Persistence | Trough | Per-second exponential decay of the phosphor trace. |
| Brightness / Line Width | Trough | |
| Detail | Trough | Beam segments drawn per frame; has no effect when both axes are Manual. |
| Brightness / Width | Trough | Line width in line mode; also sizes points when Points Only is on. |
| Points Only | Rocker | Renders each frame's beam position as a discrete point-glow instead of a connected line between positions. |
| Detail | Trough | Beam segments drawn per frame; hidden (and has no effect) when both axes are Manual. |
| Generator A / Generator B | Selector, Trough | Wave (Sine, Square, Sawtooth, Triangle, and the same 6 noise waves LFO offers), Freq, Phase, Amp, plus Seed/Harmonics/Spread/Gain/Density for the noise waves. |
| Reset Generators | Button | Zeroes Generator A/B's phase accumulators back in lock-step and clears the phosphor trail. Only shown when Generator A or B drives an axis: independent modulation of Gen A/B Freq drifts their phases apart over time, skewing the default clean-circle Lissajous shape, and this snaps it back. |

Insert FX on SCOPE applies to the full composited result, backdrop and
phosphor trace together, not the trace alone. See
Expand Down
1 change: 1 addition & 0 deletions src/content/docs/reference/modules/source.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ another program's NDI/Spout/Syphon output.
| Parameter | Control | Notes |
|---|---|---|
| Input | Selector | Video File, Camera / Capture, NDI In, Spout In (Windows only), Syphon In (macOS only). |
| Fit Mode | Selector | How the incoming frame's aspect ratio maps onto the canvas, for every Input kind. Fit letterboxes or pillarboxes with no crop. Crop fills the canvas and crops the overflow. Stretch distorts aspect to fill exactly. Native centers the frame at its exact original pixel size with no scaling. |
| File / Device | file picker, text field | Meaning depends on Input: a file path for Video File, an OS capture handle for Camera / Capture, or a sender/server name to connect to for NDI/Spout/Syphon In. |
| Loop | Rocker | File input only. |
| Speed | Trough | 0 to 4.0x. At 0 the value field reads `Pause`. Playback is forward only; there is no reverse. |
Expand Down
29 changes: 29 additions & 0 deletions src/content/docs/reference/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ module.
| `F2` | Rename selected module |
| `Delete` | Remove selected module |
| `Ctrl+D` | Duplicate selected module |
| `Ctrl+C` | Copy selected module |
| `Ctrl+V` | Paste onto selected module. Adds a new module of the copied type after it if the types differ. |

## Patches (while hovered)

Expand All @@ -62,6 +64,31 @@ same keys rename, delete, or duplicate a module in `Chain` and a patch slot
in `Patches`; which one fires depends on which window the mouse is over,
not which one has focus.

## Insert FX Slot (while hovered)

Live while the mouse is over an Insert FX slot row.

| Keys | Action |
|---|---|
| `Ctrl+C` | Copy this Insert FX slot |
| `Ctrl+V` | Paste Insert FX. Works for any effect type. |

## Section Header (while hovered)

Live while the mouse is over a parameter section header, for example OSC's
`Frequency` or SHAPE's `Symmetry`.

| Keys | Action |
|---|---|
| `Ctrl+C` | Copy this section's values |
| `Ctrl+V` | Paste values. Only works if the target section has the same title. |
| Right-click | Copy/Paste menu, same actions as the keys above |

**NOTE:** `Ctrl+C` and `Ctrl+V` are bound four times across this page: Chain,
Insert FX Slot, Section Header, and Any Parameter below. Which one fires
depends on where the mouse is hovering, the same rule that governs the
`F2`, `Delete`, and `Ctrl+D` note above.

## Any Parameter

Mouse gestures on any parameter control.
Expand All @@ -73,3 +100,5 @@ Mouse gestures on any parameter control.
| Ctrl+Click | Type an exact value |
| Double-click, Alt+Click | Reset to default |
| Right-click | Modulation menu (assign, MIDI Learn, reset) |
| `Ctrl+C` | Copy this parameter's value |
| `Ctrl+V` | Paste value. Only works if the target parameter has the same name. |
Loading