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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
26 changes: 26 additions & 0 deletions build/installer.nsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
;
; Filesmith setup - ported from Prism's installer kit.
;
; The window is already playing something, and setup is the type over it. There
; is no title bar and there are no controls: each screen is a video frame with an
; alpha overlay composited on top, and the pointer is hit-tested by hand.
;
; Rebuild the media after changing over.html or the source clip:
; npx electron build/installer/make-over.cjs 1440
; npx electron build/installer/make-over.cjs 960
; node build/installer/make-loop.cjs <clip.mp4>
;

!macro customHeader
; Windows must not stretch us: at 150% we load the 2x art instead.
ManifestDPIAware true
!macroend

; The uninstaller is compiled from this same script with BUILD_UNINSTALLER set,
; and it has none of these pages. Without the guard its pass would resize a
; window it never draws, and warn about every function it does not call.
!ifndef BUILD_UNINSTALLER
!include "installer\kit.nsh" ; the window
!include "installer\video.nsh" ; the picture, and every click in it
!include "installer\pages.nsh" ; the four screens
!endif
50 changes: 50 additions & 0 deletions build/installer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Setup

Setup is a video with the UI composited over it. NSIS cannot play video, so it
plays one itself: every tick it decodes a JPEG through GDI+, alpha-blends the
screen's overlay on top, and copies the result into the bitmap a single static
control shows. There are no buttons; the same tick hit-tests the pointer.

## The pieces

| file | what it is |
|---|---|
| `over.html` | the foreground: type, buttons, the caption. The only place copy lives. |
| `make-over.cjs` | renders `over.html` into alpha overlays + `over.nsh` rectangles |
| `make-loop.cjs` | turns a source clip into the frame sequence, and makes it loop |
| `kit.nsh` | the frameless window: size, DPI, GDI+, unpacking |
| `video.nsh` | the player: decode, composite, hover, clicks, dragging |
| `pages.nsh` | the four screens, and what each click means |
| `assoc.nsh` | file type registration (offered, never taken) |
| `media/<size>/` | generated: `v/` frames, `o/` overlays. Not hand-edited. |

## Changing the words or the layout

Edit `over.html`, then regenerate both overlay sets:

```
npx electron build/installer/make-over.cjs 1440
npx electron build/installer/make-over.cjs 960
```

They write straight into `media/<size>/o`, which is what the installer packs.
Do not stage them anywhere else: an intermediate folder is a thing to forget.

## Changing the clip

```
node build/installer/make-loop.cjs "C:\path\to\clip.mp4"
```

It reports how many frames it produced; put that number in `FRAMES` in
`video.nsh`, and set `TICK` to 1000 / the clip's frame rate. The script reads
how many frames the source actually yielded and sizes the loop to fit, and
crossfades the tail into the head so the wrap is smaller than an ordinary frame
step. It prints both numbers so you can check.

## What it costs

Roughly 10 MB of frames at 800x600, plus about 3 MB of overlays. The footage
ships at one size for every display because it is defocused ink and an upscale
is invisible on it; the type is a separate overlay and renders at the display's
own resolution.
158 changes: 158 additions & 0 deletions build/installer/kit.nsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
;
; Filesmith setup, part one: the window.
;
; There is no wizard here. NSIS's frame, header, hairline and three buttons are
; all removed, the window is sized to the picture, and everything you see after
; that is drawn by video.nsh into a single control.
;
; The caption is part of the picture too, which is why the window has no title
; bar: dragging is handled by hit-testing the top strip ourselves.
;

!include "LogicLib.nsh"
!include "nsDialogs.nsh"
!include "WinMessages.nsh"

!define ART_W 640
!define ART_H 480

!define /ifndef SW_HIDE 0
!define K_LOGPIXELSX 88
!define K_SWP_NOACT 0x0010
!define K_SWP_FRAME 0x0027 ; FRAMECHANGED|NOMOVE|NOSIZE|NOZORDER

Var Dpi ; 96 at 100%, 216 at 225%
Var MediaSize ; which set of frames and overlays suits this display
Var ArtDir
Var Dialog
Var RunAfter
Var Gdip ; GDI+ token, started once and never stopped: setup outlives it
Var Font

!macro HIDE_CHILD ID
GetDlgItem $0 $HWNDPARENT ${ID}
${If} $0 <> 0
ShowWindow $0 ${SW_HIDE}
${EndIf}
!macroend

; NSIS puts Back, Next and Cancel back on every page it owns, so this runs on
; each one rather than once at startup.
!macro HIDE_WIZARD_BUTTONS
!insertmacro HIDE_CHILD 1
!insertmacro HIDE_CHILD 2
!insertmacro HIDE_CHILD 3
!macroend

!define MUI_CUSTOMFUNCTION_GUIINIT filesmithGuiInit

Function filesmithGuiInit
; --- what are we drawing at? -------------------------------------------------
System::Call 'user32::GetDpiForSystem() i .r0 ? e'
Pop $1
${If} $0 < 72
System::Call 'user32::GetDC(p 0) p .r1'
System::Call 'gdi32::GetDeviceCaps(p $1, i ${K_LOGPIXELSX}) i .r0'
System::Call 'user32::ReleaseDC(p 0, p $1)'
${EndIf}
${If} $0 < 72
StrCpy $0 96
${EndIf}
StrCpy $Dpi $0
; Pick the set whose frames are the size of the window, so every frame is
; drawn one to one. Decoding a 1440 wide JPEG to fill a 960 wide window costs
; the frame rate twice over: once to decode, once to rescale.
${If} $Dpi >= 180
StrCpy $MediaSize "1440"
${Else}
StrCpy $MediaSize "960"
${EndIf}

; A default timer only fires on the 15.6 ms scheduler tick, which turns a 42 ms
; request into 47 and the frame rate into a stutter. One millisecond, please.
System::Call 'winmm::timeBeginPeriod(i 1)'

; --- GDI+, which decodes every frame and does the alpha ----------------------
System::Call '*(i 1, p 0, i 0, i 0) p .r0'
System::Call 'gdiplus::GdiplusStartup(*p .r1, p $0, p 0) i'
System::Free $0
StrCpy $Gdip $1

; --- take the frame off -------------------------------------------------------
System::Call 'user32::GetWindowLong(p $HWNDPARENT, i -16) i .r0'
IntOp $0 $0 & 0xFF30FFFF ; ~(WS_CAPTION|SYSMENU|THICKFRAME|MIN|MAXBOX)
IntOp $0 $0 | 0x02000000 ; WS_CLIPCHILDREN: never paint under the page
System::Call 'user32::SetWindowLong(p $HWNDPARENT, i -16, i $0)'
System::Call 'user32::GetWindowLong(p $HWNDPARENT, i -20) i .r0'
IntOp $0 $0 | 0x40000 ; WS_EX_APPWINDOW: keep the taskbar button
System::Call 'user32::SetWindowLong(p $HWNDPARENT, i -20, i $0)'
System::Call 'user32::SetWindowPos(p $HWNDPARENT, p 0, i 0, i 0, i 0, i 0, i ${K_SWP_FRAME})'

!insertmacro HIDE_WIZARD_BUTTONS
!insertmacro HIDE_CHILD 1028 ; branding strip
!insertmacro HIDE_CHILD 1256 ; hairline
!insertmacro HIDE_CHILD 1034 ; header plate
!insertmacro HIDE_CHILD 1035
!insertmacro HIDE_CHILD 1036
!insertmacro HIDE_CHILD 1037
!insertmacro HIDE_CHILD 1038
!insertmacro HIDE_CHILD 1039

; --- size it to the picture and centre it on the work area --------------------
IntOp $R2 ${ART_W} * $Dpi
IntOp $R2 $R2 / 96
IntOp $R3 ${ART_H} * $Dpi
IntOp $R3 $R3 / 96

System::Call '*(i,i,i,i) p .r9'
System::Call 'user32::SystemParametersInfo(i 0x30, i 0, p $9, i 0)'
System::Call '*$9(i .r0, i .r1, i .r2, i .r3)'
System::Free $9
IntOp $2 $2 - $0
IntOp $3 $3 - $1
IntOp $2 $2 - $R2
IntOp $2 $2 / 2
IntOp $2 $2 + $0
IntOp $3 $3 - $R3
IntOp $3 $3 / 2
IntOp $3 $3 + $1
System::Call 'user32::SetWindowPos(p $HWNDPARENT, p 0, i $2, i $3, i $R2, i $R3, i ${K_SWP_NOACT})'

; rounded corners, since there is no frame left to round them for us
IntOp $0 $R2 + 1
IntOp $1 $R3 + 1
IntOp $4 14 * $Dpi
IntOp $4 $4 / 96
System::Call 'gdi32::CreateRoundRectRgn(i 0, i 0, i $0, i $1, i $4, i $4) p .r5'
System::Call 'user32::SetWindowRgn(p $HWNDPARENT, p $5, i 1)'

; the inner dialog is the whole window now
GetDlgItem $0 $HWNDPARENT 1018
System::Call 'user32::SetWindowPos(p $0, p 0, i 0, i 0, i $R2, i $R3, i ${K_SWP_NOACT})'

; --- the one font setup writes with ------------------------------------------
IntOp $0 12 * $Dpi
IntOp $0 $0 / 96
IntOp $0 0 - $0
System::Call 'gdi32::CreateFont(i $0, i 0, i 0, i 0, i 400, i 0, i 0, i 0, \
i 1, i 0, i 0, i 5, i 0, t "Segoe UI") p .r1'
StrCpy $Font $1
FunctionEnd

; The loop and the overlays ride along inside setup, and the set that matches
; this display is unpacked before the first page draws.
!macro UNPACK_MEDIA
StrCpy $ArtDir "$PLUGINSDIR\m"
CreateDirectory "$ArtDir\v"
CreateDirectory "$ArtDir\o"
; The footage is one size for every display: it is defocused ink, and an
; upscale is invisible on it. Only the type changes with the DPI.
SetOutPath "$ArtDir\v"
File "${BUILD_RESOURCES_DIR}\installer\media\800\v\*.jpg"
SetOutPath "$ArtDir\o"
${If} $MediaSize == "1440"
File "${BUILD_RESOURCES_DIR}\installer\media\1440\o\*.png"
${Else}
File "${BUILD_RESOURCES_DIR}\installer\media\960\o\*.png"
${EndIf}
!macroend
91 changes: 91 additions & 0 deletions build/installer/make-loop.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Builds the video loop setup plays, from a source clip.
*
* node build/installer/make-loop.cjs "C:\\path\\to\\clip.mp4"
*
* Out: build/installer/media/<1440|960>/v/000.jpg ... the loop
*
* Three things worth knowing:
*

* - The clip does not loop, so we make it loop. The last K frames are
* crossfaded onto the first K, which leaves frame 59 running into frame 0
* with a smaller step than an ordinary frame-to-frame one.
*
* - The install page cannot animate: NSIS runs the section on the script
* thread, so nothing can call back into script while files are being
* written. That screen draws frame 30 once and lets the progress bar move.
*/
const { execFileSync } = require('node:child_process')

Check failure on line 19 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

'require' is not defined

Check failure on line 19 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

A `require()` style import is forbidden
const fs = require('node:fs')

Check failure on line 20 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

'require' is not defined

Check failure on line 20 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

A `require()` style import is forbidden
const os = require('node:os')

Check failure on line 21 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

'require' is not defined

Check failure on line 21 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

A `require()` style import is forbidden
const path = require('node:path')

Check failure on line 22 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

'require' is not defined

Check failure on line 22 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

A `require()` style import is forbidden

const SRC = process.argv[2]

Check failure on line 24 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

'process' is not defined
const HERE = __dirname

Check failure on line 25 in build/installer/make-loop.cjs

View workflow job for this annotation

GitHub Actions / check

'__dirname' is not defined
const MEDIA = path.join(HERE, 'media')

const START = 0 // from the top: the whole clip, not a window into it
const LEN = 18
const FPS = 24 // this source runs at 23.976, so 24 keeps them one to one
const N = 380 // frames: the whole clip, end to end
const K = 36 // of which this many are crossfaded, a second and a half
// No grade. The clip is already monochrome, so a hue rotation would do nothing
// to it anyway, and it was asked for untouched.
const GRADE = ''
// One size for every display. The overlay stays per DPI so the type is always
// sharp, but the footage is defocused ink: a 960 to 1440 upscale is invisible on
// it, and halving the payload is what buys twenty seconds at twenty four frames.
// 800x600 for a 960 or 1440 window. Defocused ink is the one subject where an
// upscale costs nothing visible, and at fourteen hundred frames every pixel of
// width is megabytes. The type is a separate overlay and stays native.
const SIZES = [{ w: 800, h: 600, q: 62 }]

const run = (bin, args) => execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })

if (!SRC || !fs.existsSync(SRC)) {
console.error('usage: node make-loop.cjs <source clip>')
process.exit(1)
}

for (const { w, h, q } of SIZES) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'filesmith-loop-'))
const raw = path.join(tmp, 'raw')
fs.mkdirSync(raw)

// straight from the master: graded, cover-cropped to the window, at its size
run('ffmpeg', ['-y', '-v', 'error', '-ss', String(START), '-t', String(LEN), '-i', SRC,
'-vf', [`fps=${FPS}`, GRADE, `scale=${w}:${h}:force_original_aspect_ratio=increase`, `crop=${w}:${h}`]
.filter(Boolean).join(','),
path.join(raw, '%03d.png')])

// However many frames the source really yielded: asking for one more than
// exists is the difference between a loop and a crash.
const have = fs.readdirSync(raw).length
const n = Math.min(N, have - K)
if (n < K * 2) throw new Error(`only ${have} frames available, need at least ${K * 3}`)

const f = (i) => path.join(raw, `${String(i + 1).padStart(3, '0')}.png`)
const vdir = path.join(MEDIA, String(w), 'v')
fs.rmSync(vdir, { recursive: true, force: true })
fs.mkdirSync(vdir, { recursive: true })

for (let i = 0; i < n; i++) {
const dst = path.join(vdir, `${String(i).padStart(3, '0')}.jpg`)
if (i < K) {
// out[i] = frame[N+i] fading out under frame[i] fading in, so that the
// wrap from the last frame back to the first one is already in progress
const pct = Math.round((100 * i) / K)
const blend = path.join(tmp, 'b.png')
run('magick', [f(n + i), f(i), '-define', `compose:args=${pct}`, '-compose', 'blend', '-composite', blend])
run('magick', [blend, '-quality', String(q), '-sampling-factor', '4:2:0', '-strip', dst])
} else {
run('magick', [f(i), '-quality', String(q), '-sampling-factor', '4:2:0', '-strip', dst])
}
}

fs.rmSync(tmp, { recursive: true, force: true })
const bytes = fs.readdirSync(vdir).reduce((n, x) => n + fs.statSync(path.join(vdir, x)).size, 0)
console.log(`${w}x${h}: ${n} frames of ${have} available, ${(bytes / 1e6).toFixed(1)} MB`)
console.log(` set FRAMES to ${n} in video.nsh`)
}
Loading
Loading