reelson 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +180 -0
- package/bin/reelson.js +649 -0
- package/bin/reelson.ts +676 -0
- package/bin/run.js +5 -0
- package/demo.config.example.json +51 -0
- package/docs/demo.webp +0 -0
- package/docs/prompting.md +72 -0
- package/docs/style-guide.md +117 -0
- package/examples/demo.config.json +18 -0
- package/examples/todo-add-item/scenario.ts +45 -0
- package/examples/todo-add-item/video.json +35 -0
- package/music/README.md +7 -0
- package/package.json +68 -0
- package/skills/reelson-compose/SKILL.md +226 -0
- package/skills/reelson-compose/schemas/video.schema.json +172 -0
- package/skills/reelson-compose/scripts/build.js +424 -0
- package/skills/reelson-compose/scripts/build.ts +532 -0
- package/skills/reelson-compose/scripts/captions.js +24 -0
- package/skills/reelson-compose/scripts/captions.ts +40 -0
- package/skills/reelson-compose/scripts/check.js +80 -0
- package/skills/reelson-compose/scripts/check.ts +93 -0
- package/skills/reelson-compose/scripts/composition.js +163 -0
- package/skills/reelson-compose/scripts/composition.ts +241 -0
- package/skills/reelson-compose/scripts/hyperframes.js +138 -0
- package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
- package/skills/reelson-compose/scripts/portrait.js +150 -0
- package/skills/reelson-compose/scripts/portrait.ts +218 -0
- package/skills/reelson-compose/scripts/project.js +238 -0
- package/skills/reelson-compose/scripts/project.ts +324 -0
- package/skills/reelson-compose/scripts/studio.js +330 -0
- package/skills/reelson-compose/scripts/studio.ts +394 -0
- package/skills/reelson-compose/scripts/timeline.js +271 -0
- package/skills/reelson-compose/scripts/timeline.ts +514 -0
- package/skills/reelson-compose/scripts/tts.js +303 -0
- package/skills/reelson-compose/scripts/tts.ts +371 -0
- package/skills/reelson-compose/scripts/verify.js +190 -0
- package/skills/reelson-compose/scripts/verify.ts +209 -0
- package/skills/reelson-compose/scripts/voice.js +178 -0
- package/skills/reelson-compose/scripts/voice.ts +224 -0
- package/skills/reelson-compose/scripts/zooms.js +259 -0
- package/skills/reelson-compose/scripts/zooms.ts +333 -0
- package/skills/reelson-compose/sections/README.md +5 -0
- package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
- package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
- package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
- package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
- package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
- package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
- package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
- package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
- package/skills/reelson-compose/sections/intro/split/section.css +30 -0
- package/skills/reelson-compose/sections/intro/split/section.html +12 -0
- package/skills/reelson-compose/sections/intro/split/section.js +19 -0
- package/skills/reelson-compose/sections/intro/split/section.json +9 -0
- package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
- package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
- package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
- package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
- package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
- package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
- package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
- package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
- package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
- package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
- package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
- package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
- package/skills/reelson-compose/studio/index.html +81 -0
- package/skills/reelson-compose/studio/studio.css +142 -0
- package/skills/reelson-compose/studio/studio.js +1031 -0
- package/skills/reelson-compose/templates/README.md +193 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/stage.html +447 -0
- package/skills/reelson-compose/templates/classic/template.json +5 -0
- package/skills/reelson-record/SKILL.md +220 -0
- package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
- package/skills/reelson-record/scripts/capture.js +142 -0
- package/skills/reelson-record/scripts/capture.ts +190 -0
- package/skills/reelson-record/scripts/config.js +148 -0
- package/skills/reelson-record/scripts/config.ts +264 -0
- package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
- package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
- package/skills/reelson-record/scripts/doctor.js +128 -0
- package/skills/reelson-record/scripts/doctor.ts +147 -0
- package/skills/reelson-record/scripts/languages.js +29 -0
- package/skills/reelson-record/scripts/languages.ts +41 -0
- package/skills/reelson-record/scripts/record.js +365 -0
- package/skills/reelson-record/scripts/record.ts +451 -0
- package/skills/reelson-record/scripts/scenario.js +356 -0
- package/skills/reelson-record/scripts/scenario.ts +570 -0
- package/skills/reelson-record/scripts/validate.js +143 -0
- package/skills/reelson-record/scripts/validate.ts +179 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injected into every page before load. Playwright's synthetic mouse never
|
|
3
|
+
* moves the OS cursor, and screen/video capture never shows it either, so we
|
|
4
|
+
* draw our own: a large macOS-style black arrow that follows `mousemove`, a
|
|
5
|
+
* press "squash" and an accent-coloured expanding ring on `mousedown`. Position survives
|
|
6
|
+
* navigations via sessionStorage.
|
|
7
|
+
*
|
|
8
|
+
* With `report`, every move and press in the top window is also sent to the recorder
|
|
9
|
+
* (the CURSOR_BINDING binding) with the page's clock: the cursor layer is drawn from that
|
|
10
|
+
* log. With `draw: false` nothing is drawn — the video draws the cursor instead.
|
|
11
|
+
*/
|
|
12
|
+
export const CURSOR_BINDING = '__reelsonCursor';
|
|
13
|
+
export function cursorOverlayScript(accent = '#dc2626', options = {}) {
|
|
14
|
+
const { draw = true, report = false } = options;
|
|
15
|
+
return `
|
|
16
|
+
(() => {
|
|
17
|
+
if (window.__demoCursor) return;
|
|
18
|
+
window.__demoCursor = true;
|
|
19
|
+
|
|
20
|
+
const REPORT = ${JSON.stringify(report)} && window.top === window;
|
|
21
|
+
const send = (type, e) => {
|
|
22
|
+
if (!REPORT) return;
|
|
23
|
+
const t = Date.now();
|
|
24
|
+
try { window[${JSON.stringify(CURSOR_BINDING)}]({ type, x: e.clientX, y: e.clientY, t }); } catch {}
|
|
25
|
+
};
|
|
26
|
+
document.addEventListener('mousemove', (e) => send('move', e), true);
|
|
27
|
+
document.addEventListener('mousedown', (e) => send('down', e), true);
|
|
28
|
+
document.addEventListener('mouseup', (e) => send('up', e), true);
|
|
29
|
+
if (!${JSON.stringify(draw)}) return;
|
|
30
|
+
|
|
31
|
+
const SIZE = 44; // px
|
|
32
|
+
const ACCENT = ${JSON.stringify(accent)};
|
|
33
|
+
// Arrow tip in the 24-unit viewBox is (5.5, 2.5); this is where the pointer actually is.
|
|
34
|
+
const TIP_X = 5.5 / 24 * SIZE;
|
|
35
|
+
const TIP_Y = 2.5 / 24 * SIZE;
|
|
36
|
+
const style = document.createElement('style');
|
|
37
|
+
style.textContent = \`
|
|
38
|
+
#__demo-cursor { position: fixed; left: 0; top: 0; width: \${SIZE}px; height: \${SIZE}px; z-index: 2147483647;
|
|
39
|
+
pointer-events: none; will-change: transform;
|
|
40
|
+
filter: drop-shadow(0 2px 3px rgba(0,0,0,.35)) drop-shadow(0 8px 16px rgba(0,0,0,.25)); }
|
|
41
|
+
/* The press squash scales the SVG child about the arrow tip. Never scale the
|
|
42
|
+
container: its scale composes outside the positioning translate and drags the
|
|
43
|
+
cursor toward the page origin while it animates. */
|
|
44
|
+
#__demo-cursor svg { display: block; transform-origin: \${TIP_X}px \${TIP_Y}px; }
|
|
45
|
+
#__demo-cursor.pressed svg { animation: __demo-press .22s ease-out; }
|
|
46
|
+
@keyframes __demo-press { 0% { transform: scale(1) } 40% { transform: scale(.82) } 100% { transform: scale(1) } }
|
|
47
|
+
#__demo-ripple, #__demo-ripple2 { position: fixed; border-radius: 50%; z-index: 2147483646; pointer-events: none;
|
|
48
|
+
transform: translate(-50%, -50%) scale(.2); opacity: 0; }
|
|
49
|
+
#__demo-ripple { width: 84px; height: 84px; border: 4px solid \${ACCENT}; background: color-mix(in srgb, \${ACCENT} 22%, transparent); }
|
|
50
|
+
#__demo-ripple2 { width: 84px; height: 84px; border: 2px solid color-mix(in srgb, \${ACCENT} 90%, transparent); }
|
|
51
|
+
#__demo-ripple.go { animation: __demo-ripple .55s cubic-bezier(.2,.7,.3,1) forwards; }
|
|
52
|
+
#__demo-ripple2.go { animation: __demo-ripple .8s .08s cubic-bezier(.2,.7,.3,1) forwards; }
|
|
53
|
+
@keyframes __demo-ripple { 0% { transform: translate(-50%,-50%) scale(.2); opacity: 1 }
|
|
54
|
+
100% { transform: translate(-50%,-50%) scale(1.25); opacity: 0 } }
|
|
55
|
+
\`;
|
|
56
|
+
|
|
57
|
+
const cursor = document.createElement('div');
|
|
58
|
+
cursor.id = '__demo-cursor';
|
|
59
|
+
cursor.innerHTML = '<svg viewBox="0 0 24 24" width="' + SIZE + '" height="' + SIZE + '">'
|
|
60
|
+
+ '<path d="M5.5 2.5v17.4l4.6-4.4 2.9 6.5 3-1.3-2.9-6.4h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
|
|
61
|
+
const ripple = document.createElement('div');
|
|
62
|
+
ripple.id = '__demo-ripple';
|
|
63
|
+
const ripple2 = document.createElement('div');
|
|
64
|
+
ripple2.id = '__demo-ripple2';
|
|
65
|
+
|
|
66
|
+
let saved = { x: -100, y: -100 };
|
|
67
|
+
try { saved = JSON.parse(sessionStorage.getItem('__demoCursorPos') || 'null') || saved; } catch {}
|
|
68
|
+
|
|
69
|
+
const place = (x, y) => {
|
|
70
|
+
cursor.style.transform = 'translate(' + (x - TIP_X) + 'px,' + (y - TIP_Y) + 'px)';
|
|
71
|
+
try { sessionStorage.setItem('__demoCursorPos', JSON.stringify({ x, y })); } catch {}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const mount = () => {
|
|
75
|
+
const host = document.documentElement;
|
|
76
|
+
host.appendChild(style); host.appendChild(ripple2); host.appendChild(ripple); host.appendChild(cursor);
|
|
77
|
+
place(saved.x, saved.y);
|
|
78
|
+
};
|
|
79
|
+
if (document.documentElement) mount(); else document.addEventListener('DOMContentLoaded', mount);
|
|
80
|
+
|
|
81
|
+
document.addEventListener('mousemove', (e) => place(e.clientX, e.clientY), true);
|
|
82
|
+
document.addEventListener('mousedown', (e) => {
|
|
83
|
+
for (const r of [ripple, ripple2]) {
|
|
84
|
+
r.style.left = e.clientX + 'px'; r.style.top = e.clientY + 'px';
|
|
85
|
+
r.classList.remove('go'); void r.offsetWidth; r.classList.add('go');
|
|
86
|
+
}
|
|
87
|
+
cursor.classList.remove('pressed'); void cursor.offsetWidth; cursor.classList.add('pressed');
|
|
88
|
+
}, true);
|
|
89
|
+
})();
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Hides local-only UI (debug toolbars, environment badges, dev-login buttons)
|
|
94
|
+
* in every recorded page. Selectors come from COMMON_DEV_CHROME plus the
|
|
95
|
+
* project's `record.hideSelectors` in demo.config.json.
|
|
96
|
+
*/
|
|
97
|
+
export function hideDevChromeScript(selectors) {
|
|
98
|
+
if (selectors.length === 0) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
// One rule per selector: an invalid selector in a list drops the whole rule.
|
|
102
|
+
const css = selectors.map((s) => `${s} { display: none !important; }`).join('\n');
|
|
103
|
+
return `
|
|
104
|
+
(() => {
|
|
105
|
+
const style = document.createElement('style');
|
|
106
|
+
style.textContent = ${JSON.stringify(css)};
|
|
107
|
+
const mount = () => document.head.appendChild(style);
|
|
108
|
+
if (document.head) mount(); else document.addEventListener('DOMContentLoaded', mount);
|
|
109
|
+
})();
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injected into every page before load. Playwright's synthetic mouse never
|
|
3
|
+
* moves the OS cursor, and screen/video capture never shows it either, so we
|
|
4
|
+
* draw our own: a large macOS-style black arrow that follows `mousemove`, a
|
|
5
|
+
* press "squash" and an accent-coloured expanding ring on `mousedown`. Position survives
|
|
6
|
+
* navigations via sessionStorage.
|
|
7
|
+
*
|
|
8
|
+
* With `report`, every move and press in the top window is also sent to the recorder
|
|
9
|
+
* (the CURSOR_BINDING binding) with the page's clock: the cursor layer is drawn from that
|
|
10
|
+
* log. With `draw: false` nothing is drawn — the video draws the cursor instead.
|
|
11
|
+
*/
|
|
12
|
+
export const CURSOR_BINDING = '__reelsonCursor'
|
|
13
|
+
|
|
14
|
+
export interface CursorEvent {
|
|
15
|
+
type: 'move' | 'down' | 'up'
|
|
16
|
+
x: number
|
|
17
|
+
y: number
|
|
18
|
+
/** Date.now() in the page when the event was handled. */
|
|
19
|
+
t: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cursorOverlayScript(accent: string = '#dc2626', options: { draw?: boolean; report?: boolean } = {}): string {
|
|
23
|
+
const { draw = true, report = false } = options
|
|
24
|
+
return `
|
|
25
|
+
(() => {
|
|
26
|
+
if (window.__demoCursor) return;
|
|
27
|
+
window.__demoCursor = true;
|
|
28
|
+
|
|
29
|
+
const REPORT = ${JSON.stringify(report)} && window.top === window;
|
|
30
|
+
const send = (type, e) => {
|
|
31
|
+
if (!REPORT) return;
|
|
32
|
+
const t = Date.now();
|
|
33
|
+
try { window[${JSON.stringify(CURSOR_BINDING)}]({ type, x: e.clientX, y: e.clientY, t }); } catch {}
|
|
34
|
+
};
|
|
35
|
+
document.addEventListener('mousemove', (e) => send('move', e), true);
|
|
36
|
+
document.addEventListener('mousedown', (e) => send('down', e), true);
|
|
37
|
+
document.addEventListener('mouseup', (e) => send('up', e), true);
|
|
38
|
+
if (!${JSON.stringify(draw)}) return;
|
|
39
|
+
|
|
40
|
+
const SIZE = 44; // px
|
|
41
|
+
const ACCENT = ${JSON.stringify(accent)};
|
|
42
|
+
// Arrow tip in the 24-unit viewBox is (5.5, 2.5); this is where the pointer actually is.
|
|
43
|
+
const TIP_X = 5.5 / 24 * SIZE;
|
|
44
|
+
const TIP_Y = 2.5 / 24 * SIZE;
|
|
45
|
+
const style = document.createElement('style');
|
|
46
|
+
style.textContent = \`
|
|
47
|
+
#__demo-cursor { position: fixed; left: 0; top: 0; width: \${SIZE}px; height: \${SIZE}px; z-index: 2147483647;
|
|
48
|
+
pointer-events: none; will-change: transform;
|
|
49
|
+
filter: drop-shadow(0 2px 3px rgba(0,0,0,.35)) drop-shadow(0 8px 16px rgba(0,0,0,.25)); }
|
|
50
|
+
/* The press squash scales the SVG child about the arrow tip. Never scale the
|
|
51
|
+
container: its scale composes outside the positioning translate and drags the
|
|
52
|
+
cursor toward the page origin while it animates. */
|
|
53
|
+
#__demo-cursor svg { display: block; transform-origin: \${TIP_X}px \${TIP_Y}px; }
|
|
54
|
+
#__demo-cursor.pressed svg { animation: __demo-press .22s ease-out; }
|
|
55
|
+
@keyframes __demo-press { 0% { transform: scale(1) } 40% { transform: scale(.82) } 100% { transform: scale(1) } }
|
|
56
|
+
#__demo-ripple, #__demo-ripple2 { position: fixed; border-radius: 50%; z-index: 2147483646; pointer-events: none;
|
|
57
|
+
transform: translate(-50%, -50%) scale(.2); opacity: 0; }
|
|
58
|
+
#__demo-ripple { width: 84px; height: 84px; border: 4px solid \${ACCENT}; background: color-mix(in srgb, \${ACCENT} 22%, transparent); }
|
|
59
|
+
#__demo-ripple2 { width: 84px; height: 84px; border: 2px solid color-mix(in srgb, \${ACCENT} 90%, transparent); }
|
|
60
|
+
#__demo-ripple.go { animation: __demo-ripple .55s cubic-bezier(.2,.7,.3,1) forwards; }
|
|
61
|
+
#__demo-ripple2.go { animation: __demo-ripple .8s .08s cubic-bezier(.2,.7,.3,1) forwards; }
|
|
62
|
+
@keyframes __demo-ripple { 0% { transform: translate(-50%,-50%) scale(.2); opacity: 1 }
|
|
63
|
+
100% { transform: translate(-50%,-50%) scale(1.25); opacity: 0 } }
|
|
64
|
+
\`;
|
|
65
|
+
|
|
66
|
+
const cursor = document.createElement('div');
|
|
67
|
+
cursor.id = '__demo-cursor';
|
|
68
|
+
cursor.innerHTML = '<svg viewBox="0 0 24 24" width="' + SIZE + '" height="' + SIZE + '">'
|
|
69
|
+
+ '<path d="M5.5 2.5v17.4l4.6-4.4 2.9 6.5 3-1.3-2.9-6.4h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
|
|
70
|
+
const ripple = document.createElement('div');
|
|
71
|
+
ripple.id = '__demo-ripple';
|
|
72
|
+
const ripple2 = document.createElement('div');
|
|
73
|
+
ripple2.id = '__demo-ripple2';
|
|
74
|
+
|
|
75
|
+
let saved = { x: -100, y: -100 };
|
|
76
|
+
try { saved = JSON.parse(sessionStorage.getItem('__demoCursorPos') || 'null') || saved; } catch {}
|
|
77
|
+
|
|
78
|
+
const place = (x, y) => {
|
|
79
|
+
cursor.style.transform = 'translate(' + (x - TIP_X) + 'px,' + (y - TIP_Y) + 'px)';
|
|
80
|
+
try { sessionStorage.setItem('__demoCursorPos', JSON.stringify({ x, y })); } catch {}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const mount = () => {
|
|
84
|
+
const host = document.documentElement;
|
|
85
|
+
host.appendChild(style); host.appendChild(ripple2); host.appendChild(ripple); host.appendChild(cursor);
|
|
86
|
+
place(saved.x, saved.y);
|
|
87
|
+
};
|
|
88
|
+
if (document.documentElement) mount(); else document.addEventListener('DOMContentLoaded', mount);
|
|
89
|
+
|
|
90
|
+
document.addEventListener('mousemove', (e) => place(e.clientX, e.clientY), true);
|
|
91
|
+
document.addEventListener('mousedown', (e) => {
|
|
92
|
+
for (const r of [ripple, ripple2]) {
|
|
93
|
+
r.style.left = e.clientX + 'px'; r.style.top = e.clientY + 'px';
|
|
94
|
+
r.classList.remove('go'); void r.offsetWidth; r.classList.add('go');
|
|
95
|
+
}
|
|
96
|
+
cursor.classList.remove('pressed'); void cursor.offsetWidth; cursor.classList.add('pressed');
|
|
97
|
+
}, true);
|
|
98
|
+
})();
|
|
99
|
+
`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Hides local-only UI (debug toolbars, environment badges, dev-login buttons)
|
|
104
|
+
* in every recorded page. Selectors come from COMMON_DEV_CHROME plus the
|
|
105
|
+
* project's `record.hideSelectors` in demo.config.json.
|
|
106
|
+
*/
|
|
107
|
+
export function hideDevChromeScript(selectors: string[]): string {
|
|
108
|
+
if (selectors.length === 0) {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
// One rule per selector: an invalid selector in a list drops the whole rule.
|
|
112
|
+
const css = selectors.map((s) => `${s} { display: none !important; }`).join('\n')
|
|
113
|
+
|
|
114
|
+
return `
|
|
115
|
+
(() => {
|
|
116
|
+
const style = document.createElement('style');
|
|
117
|
+
style.textContent = ${JSON.stringify(css)};
|
|
118
|
+
const mount = () => document.head.appendChild(style);
|
|
119
|
+
if (document.head) mount(); else document.addEventListener('DOMContentLoaded', mount);
|
|
120
|
+
})();
|
|
121
|
+
`
|
|
122
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reelson doctor`: is this machine ready to record and render, and does the cursor layer
|
|
3
|
+
* line up with the footage here? Checks the tools, then films a local test page the way
|
|
4
|
+
* `reelson record` does (screencast + the page's cursor log) with a magenta dot drawn at the
|
|
5
|
+
* pointer, finds the dot in every frame and measures how far the logged cursor is from it.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
import { startScreencast } from "./capture.js";
|
|
12
|
+
import { CURSOR_BINDING, cursorOverlayScript } from "./cursor-overlay.js";
|
|
13
|
+
export async function doctor(log = console.log) {
|
|
14
|
+
let problems = 0;
|
|
15
|
+
const check = (ok, good, bad) => {
|
|
16
|
+
log(`${ok ? '✓' : '✗'} ${ok ? good : bad}`);
|
|
17
|
+
if (!ok)
|
|
18
|
+
problems++;
|
|
19
|
+
};
|
|
20
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
21
|
+
check(major > 22 || (major === 22 && minor >= 18), `Node ${process.versions.node}`, `Node ${process.versions.node} — reelson needs 22.18+`);
|
|
22
|
+
for (const tool of ['ffmpeg', 'ffprobe']) {
|
|
23
|
+
const found = spawnSync(tool, ['-version'], { encoding: 'utf8' });
|
|
24
|
+
check(found.status === 0, `${found.stdout.split('\n')[0]}`, `${tool} not found — brew install ffmpeg`);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const { hyperframesDist, HYPERFRAMES_VERSION } = await import("../../reelson-compose/scripts/hyperframes.js");
|
|
28
|
+
hyperframesDist();
|
|
29
|
+
check(true, `HyperFrames ${HYPERFRAMES_VERSION}`, '');
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
check(false, '', `HyperFrames: ${error.message}`);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const sync = await measureCursorSync();
|
|
36
|
+
check(sync.fps >= 30, `screencast: ${sync.fps.toFixed(0)} fps at 2x`, `screencast only ${sync.fps.toFixed(0)} fps — footage will stutter; try record.capture "playwright"`);
|
|
37
|
+
check(Math.abs(sync.lagMs) <= 34 && sync.errorPx <= 3, `cursor layer lines up with the footage: offset ${sync.lagMs} ms, ${sync.errorPx.toFixed(1)} px (${sync.frames} frames)`, `cursor layer is off by ${sync.lagMs} ms / ${sync.errorPx.toFixed(1)} px on this machine — use record.cursor "recorded", and report it`);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
check(false, '', `could not film a test page: ${error.message} — npx playwright install chromium`);
|
|
41
|
+
}
|
|
42
|
+
log(problems ? `\n${problems} problem(s)` : '\nready to record and render');
|
|
43
|
+
return problems;
|
|
44
|
+
}
|
|
45
|
+
const PAGE = `<body style="margin:0;background:#fff"><div id="dot" style="position:fixed;left:0;top:0;width:8px;height:8px;background:#f0f;transform:translate(-99px,-99px)"></div>
|
|
46
|
+
<script>document.addEventListener('mousemove', (e) => { dot.style.transform = 'translate(' + (e.clientX - 4) + 'px,' + (e.clientY - 4) + 'px)' }, true)</script></body>`;
|
|
47
|
+
/** Films a local page with a pointer moving over it; see the file comment. */
|
|
48
|
+
export async function measureCursorSync() {
|
|
49
|
+
const { chromium } = await import('@playwright/test');
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), 'reelson-doctor-'));
|
|
51
|
+
const viewport = { width: 1440, height: 900 };
|
|
52
|
+
const browser = await chromium.launch({ args: ['--force-device-scale-factor=2'] });
|
|
53
|
+
try {
|
|
54
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 2 });
|
|
55
|
+
const events = [];
|
|
56
|
+
await context.exposeBinding(CURSOR_BINDING, (_source, event) => {
|
|
57
|
+
events.push(event);
|
|
58
|
+
});
|
|
59
|
+
await context.addInitScript(cursorOverlayScript('#6366f1', { draw: false, report: true }));
|
|
60
|
+
const page = await context.newPage();
|
|
61
|
+
// A real navigation (setContent would replace the document the cursor log listens on).
|
|
62
|
+
await page.route('https://reelson.doctor/', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }));
|
|
63
|
+
await page.goto('https://reelson.doctor/');
|
|
64
|
+
const filming = await startScreencast(context, page, dir);
|
|
65
|
+
await page.mouse.move(200, 200);
|
|
66
|
+
for (let i = 0; i < 150; i++) {
|
|
67
|
+
const u = i / 150;
|
|
68
|
+
await page.mouse.move(200 + 1000 * u, 200 + 400 * Math.sin(u * Math.PI * 2) * 0.5 + 200 * u);
|
|
69
|
+
await page.waitForTimeout(16);
|
|
70
|
+
}
|
|
71
|
+
const frames = await filming.stop();
|
|
72
|
+
return analyse(frames, events.filter((e) => e.type === 'move'), viewport);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await browser.close();
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function analyse(frames, moves, viewport) {
|
|
80
|
+
const { width: w, height: h } = viewport;
|
|
81
|
+
// Decode every frame at CSS size in one ffmpeg run, then find the magenta dot in each.
|
|
82
|
+
const decoded = spawnSync('ffmpeg', ['-loglevel', 'error', '-i', resolve(frames[0].file, '..', 'f%06d.jpg'), '-vf', `scale=${w}:${h}`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { maxBuffer: w * h * 3 * (frames.length + 1) });
|
|
83
|
+
const size = w * h * 3;
|
|
84
|
+
const dots = [];
|
|
85
|
+
frames.forEach((frame, i) => {
|
|
86
|
+
const px = decoded.stdout.subarray(i * size, (i + 1) * size);
|
|
87
|
+
let sx = 0;
|
|
88
|
+
let sy = 0;
|
|
89
|
+
let n = 0;
|
|
90
|
+
for (let p = 0; p + 2 < px.length; p += 3) {
|
|
91
|
+
if (px[p] > 180 && px[p + 2] > 180 && px[p + 1] < 90) {
|
|
92
|
+
const k = p / 3;
|
|
93
|
+
sx += k % w;
|
|
94
|
+
sy += Math.floor(k / w);
|
|
95
|
+
n++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (n >= 12) {
|
|
99
|
+
dots.push({ t: frame.t, x: sx / n, y: sy / n });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const at = (t) => {
|
|
103
|
+
let last;
|
|
104
|
+
for (const m of moves) {
|
|
105
|
+
if (m.t <= t)
|
|
106
|
+
last = m;
|
|
107
|
+
else
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
return last;
|
|
111
|
+
};
|
|
112
|
+
const errorAt = (lag) => {
|
|
113
|
+
const errors = dots.flatMap((d) => {
|
|
114
|
+
const m = at(d.t - lag);
|
|
115
|
+
return m ? [Math.hypot(d.x - m.x, d.y - m.y)] : [];
|
|
116
|
+
});
|
|
117
|
+
errors.sort((a, b) => a - b);
|
|
118
|
+
return errors.length ? errors[errors.length >> 1] : Infinity;
|
|
119
|
+
};
|
|
120
|
+
let best = { lag: 0, error: errorAt(0) };
|
|
121
|
+
for (let lag = -100; lag <= 100; lag += 4) {
|
|
122
|
+
const error = errorAt(lag);
|
|
123
|
+
if (error < best.error - 0.05)
|
|
124
|
+
best = { lag, error };
|
|
125
|
+
}
|
|
126
|
+
const span = (frames.at(-1).t - frames[0].t) / 1000;
|
|
127
|
+
return { fps: frames.length / Math.max(span, 0.001), lagMs: best.lag, errorPx: best.error, frames: dots.length };
|
|
128
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reelson doctor`: is this machine ready to record and render, and does the cursor layer
|
|
3
|
+
* line up with the footage here? Checks the tools, then films a local test page the way
|
|
4
|
+
* `reelson record` does (screencast + the page's cursor log) with a magenta dot drawn at the
|
|
5
|
+
* pointer, finds the dot in every frame and measures how far the logged cursor is from it.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { startScreencast, type Frame } from './capture.ts'
|
|
12
|
+
import { CURSOR_BINDING, cursorOverlayScript, type CursorEvent } from './cursor-overlay.ts'
|
|
13
|
+
|
|
14
|
+
export interface SyncResult {
|
|
15
|
+
/** Frames per second while the page moves. */
|
|
16
|
+
fps: number
|
|
17
|
+
/** Video time − logged time that fits best (ms); ~0 means the layer sits on the footage. */
|
|
18
|
+
lagMs: number
|
|
19
|
+
/** Median distance (CSS px) between the logged cursor and the drawn dot at that lag. */
|
|
20
|
+
errorPx: number
|
|
21
|
+
frames: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Log = (line: string) => void
|
|
25
|
+
|
|
26
|
+
export async function doctor(log: Log = console.log): Promise<number> {
|
|
27
|
+
let problems = 0
|
|
28
|
+
const check = (ok: boolean, good: string, bad: string): void => {
|
|
29
|
+
log(`${ok ? '✓' : '✗'} ${ok ? good : bad}`)
|
|
30
|
+
if (!ok) problems++
|
|
31
|
+
}
|
|
32
|
+
const [major, minor] = process.versions.node.split('.').map(Number)
|
|
33
|
+
check(major > 22 || (major === 22 && minor >= 18), `Node ${process.versions.node}`, `Node ${process.versions.node} — reelson needs 22.18+`)
|
|
34
|
+
for (const tool of ['ffmpeg', 'ffprobe']) {
|
|
35
|
+
const found = spawnSync(tool, ['-version'], { encoding: 'utf8' })
|
|
36
|
+
check(found.status === 0, `${found.stdout.split('\n')[0]}`, `${tool} not found — brew install ffmpeg`)
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const { hyperframesDist, HYPERFRAMES_VERSION } = await import('../../reelson-compose/scripts/hyperframes.ts')
|
|
40
|
+
hyperframesDist()
|
|
41
|
+
check(true, `HyperFrames ${HYPERFRAMES_VERSION}`, '')
|
|
42
|
+
} catch (error) {
|
|
43
|
+
check(false, '', `HyperFrames: ${(error as Error).message}`)
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const sync = await measureCursorSync()
|
|
47
|
+
check(sync.fps >= 30, `screencast: ${sync.fps.toFixed(0)} fps at 2x`, `screencast only ${sync.fps.toFixed(0)} fps — footage will stutter; try record.capture "playwright"`)
|
|
48
|
+
check(
|
|
49
|
+
Math.abs(sync.lagMs) <= 34 && sync.errorPx <= 3,
|
|
50
|
+
`cursor layer lines up with the footage: offset ${sync.lagMs} ms, ${sync.errorPx.toFixed(1)} px (${sync.frames} frames)`,
|
|
51
|
+
`cursor layer is off by ${sync.lagMs} ms / ${sync.errorPx.toFixed(1)} px on this machine — use record.cursor "recorded", and report it`,
|
|
52
|
+
)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
check(false, '', `could not film a test page: ${(error as Error).message} — npx playwright install chromium`)
|
|
55
|
+
}
|
|
56
|
+
log(problems ? `\n${problems} problem(s)` : '\nready to record and render')
|
|
57
|
+
return problems
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PAGE = `<body style="margin:0;background:#fff"><div id="dot" style="position:fixed;left:0;top:0;width:8px;height:8px;background:#f0f;transform:translate(-99px,-99px)"></div>
|
|
61
|
+
<script>document.addEventListener('mousemove', (e) => { dot.style.transform = 'translate(' + (e.clientX - 4) + 'px,' + (e.clientY - 4) + 'px)' }, true)</script></body>`
|
|
62
|
+
|
|
63
|
+
/** Films a local page with a pointer moving over it; see the file comment. */
|
|
64
|
+
export async function measureCursorSync(): Promise<SyncResult> {
|
|
65
|
+
const { chromium } = await import('@playwright/test')
|
|
66
|
+
const dir = mkdtempSync(join(tmpdir(), 'reelson-doctor-'))
|
|
67
|
+
const viewport = { width: 1440, height: 900 }
|
|
68
|
+
const browser = await chromium.launch({ args: ['--force-device-scale-factor=2'] })
|
|
69
|
+
try {
|
|
70
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 2 })
|
|
71
|
+
const events: CursorEvent[] = []
|
|
72
|
+
await context.exposeBinding(CURSOR_BINDING, (_source, event: CursorEvent) => {
|
|
73
|
+
events.push(event)
|
|
74
|
+
})
|
|
75
|
+
await context.addInitScript(cursorOverlayScript('#6366f1', { draw: false, report: true }))
|
|
76
|
+
const page = await context.newPage()
|
|
77
|
+
// A real navigation (setContent would replace the document the cursor log listens on).
|
|
78
|
+
await page.route('https://reelson.doctor/', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }))
|
|
79
|
+
await page.goto('https://reelson.doctor/')
|
|
80
|
+
const filming = await startScreencast(context, page, dir)
|
|
81
|
+
|
|
82
|
+
await page.mouse.move(200, 200)
|
|
83
|
+
for (let i = 0; i < 150; i++) {
|
|
84
|
+
const u = i / 150
|
|
85
|
+
await page.mouse.move(200 + 1000 * u, 200 + 400 * Math.sin(u * Math.PI * 2) * 0.5 + 200 * u)
|
|
86
|
+
await page.waitForTimeout(16)
|
|
87
|
+
}
|
|
88
|
+
const frames = await filming.stop()
|
|
89
|
+
return analyse(frames, events.filter((e) => e.type === 'move'), viewport)
|
|
90
|
+
} finally {
|
|
91
|
+
await browser.close()
|
|
92
|
+
rmSync(dir, { recursive: true, force: true })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function analyse(frames: Frame[], moves: CursorEvent[], viewport: { width: number; height: number }): SyncResult {
|
|
97
|
+
const { width: w, height: h } = viewport
|
|
98
|
+
// Decode every frame at CSS size in one ffmpeg run, then find the magenta dot in each.
|
|
99
|
+
const decoded = spawnSync(
|
|
100
|
+
'ffmpeg',
|
|
101
|
+
['-loglevel', 'error', '-i', resolve(frames[0].file, '..', 'f%06d.jpg'), '-vf', `scale=${w}:${h}`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'],
|
|
102
|
+
{ maxBuffer: w * h * 3 * (frames.length + 1) },
|
|
103
|
+
)
|
|
104
|
+
const size = w * h * 3
|
|
105
|
+
const dots: { t: number; x: number; y: number }[] = []
|
|
106
|
+
frames.forEach((frame, i) => {
|
|
107
|
+
const px = decoded.stdout.subarray(i * size, (i + 1) * size)
|
|
108
|
+
let sx = 0
|
|
109
|
+
let sy = 0
|
|
110
|
+
let n = 0
|
|
111
|
+
for (let p = 0; p + 2 < px.length; p += 3) {
|
|
112
|
+
if (px[p] > 180 && px[p + 2] > 180 && px[p + 1] < 90) {
|
|
113
|
+
const k = p / 3
|
|
114
|
+
sx += k % w
|
|
115
|
+
sy += Math.floor(k / w)
|
|
116
|
+
n++
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (n >= 12) {
|
|
120
|
+
dots.push({ t: frame.t, x: sx / n, y: sy / n })
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
const at = (t: number): CursorEvent | undefined => {
|
|
124
|
+
let last: CursorEvent | undefined
|
|
125
|
+
for (const m of moves) {
|
|
126
|
+
if (m.t <= t) last = m
|
|
127
|
+
else break
|
|
128
|
+
}
|
|
129
|
+
return last
|
|
130
|
+
}
|
|
131
|
+
const errorAt = (lag: number): number => {
|
|
132
|
+
const errors = dots.flatMap((d) => {
|
|
133
|
+
const m = at(d.t - lag)
|
|
134
|
+
return m ? [Math.hypot(d.x - m.x, d.y - m.y)] : []
|
|
135
|
+
})
|
|
136
|
+
errors.sort((a, b) => a - b)
|
|
137
|
+
return errors.length ? errors[errors.length >> 1] : Infinity
|
|
138
|
+
}
|
|
139
|
+
let best = { lag: 0, error: errorAt(0) }
|
|
140
|
+
for (let lag = -100; lag <= 100; lag += 4) {
|
|
141
|
+
const error = errorAt(lag)
|
|
142
|
+
if (error < best.error - 0.05) best = { lag, error }
|
|
143
|
+
}
|
|
144
|
+
const span = (frames.at(-1)!.t - frames[0].t) / 1000
|
|
145
|
+
|
|
146
|
+
return { fps: frames.length / Math.max(span, 0.001), lagMs: best.lag, errorPx: best.error, frames: dots.length }
|
|
147
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const table = (recapTitle, steps, seconds) => ({
|
|
2
|
+
recapTitle,
|
|
3
|
+
stepsLabel: steps,
|
|
4
|
+
secondsLabel: seconds,
|
|
5
|
+
});
|
|
6
|
+
export const LANGUAGE_STRINGS = {
|
|
7
|
+
en: table('In short', { one: 'step', other: 'steps' }, { one: 'second', other: 'seconds' }),
|
|
8
|
+
ro: table('Pe scurt', { one: 'pas', few: 'pași', other: 'de pași' }, { one: 'secundă', few: 'secunde', other: 'de secunde' }),
|
|
9
|
+
de: table('Kurz gesagt', { one: 'Schritt', other: 'Schritte' }, { one: 'Sekunde', other: 'Sekunden' }),
|
|
10
|
+
fr: table('En bref', { one: 'étape', other: 'étapes' }, { one: 'seconde', other: 'secondes' }),
|
|
11
|
+
es: table('En resumen', { one: 'paso', other: 'pasos' }, { one: 'segundo', other: 'segundos' }),
|
|
12
|
+
it: table('In breve', { one: 'passaggio', other: 'passaggi' }, { one: 'secondo', other: 'secondi' }),
|
|
13
|
+
pt: table('Em resumo', { one: 'passo', other: 'passos' }, { one: 'segundo', other: 'segundos' }),
|
|
14
|
+
nl: table('Kort gezegd', { one: 'stap', other: 'stappen' }, { one: 'seconde', other: 'seconden' }),
|
|
15
|
+
pl: table('W skrócie', { one: 'krok', few: 'kroki', many: 'kroków', other: 'kroku' }, { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekundy' }),
|
|
16
|
+
ru: table('Коротко', { one: 'шаг', few: 'шага', many: 'шагов', other: 'шага' }, { one: 'секунда', few: 'секунды', many: 'секунд', other: 'секунды' }),
|
|
17
|
+
uk: table('Коротко', { one: 'крок', few: 'кроки', many: 'кроків', other: 'кроку' }, { one: 'секунда', few: 'секунди', many: 'секунд', other: 'секунди' }),
|
|
18
|
+
cs: table('Ve zkratce', { one: 'krok', few: 'kroky', many: 'kroku', other: 'kroků' }, { one: 'sekunda', few: 'sekundy', many: 'sekundy', other: 'sekund' }),
|
|
19
|
+
sv: table('I korthet', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
|
|
20
|
+
da: table('Kort fortalt', { one: 'trin', other: 'trin' }, { one: 'sekund', other: 'sekunder' }),
|
|
21
|
+
nb: table('Kort oppsummert', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
|
|
22
|
+
fi: table('Lyhyesti', { one: 'vaihe', other: 'vaihetta' }, { one: 'sekunti', other: 'sekuntia' }),
|
|
23
|
+
hu: table('Röviden', { one: 'lépés', other: 'lépés' }, { one: 'másodperc', other: 'másodperc' }),
|
|
24
|
+
tr: table('Kısaca', { one: 'adım', other: 'adım' }, { one: 'saniye', other: 'saniye' }),
|
|
25
|
+
};
|
|
26
|
+
/** The built-in strings for `language` ("pt-BR" falls back to "pt"), or English. */
|
|
27
|
+
export function stringsFor(language) {
|
|
28
|
+
return LANGUAGE_STRINGS[language] ?? LANGUAGE_STRINGS[language.split('-')[0]] ?? LANGUAGE_STRINGS.en;
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-card text per UI language: the recap title and the plural forms of the cover chip
|
|
3
|
+
* ("4 steps · 27 seconds"), keyed by Intl.PluralRules category. demo.config.json `language`
|
|
4
|
+
* picks one; `strings` in demo.config.json still overrides any of it. Forms are the ones
|
|
5
|
+
* used right after a number (Hungarian and Turkish keep the singular, Finnish the partitive).
|
|
6
|
+
*/
|
|
7
|
+
import type { DemoConfig } from './config.ts'
|
|
8
|
+
|
|
9
|
+
type Strings = DemoConfig['strings']
|
|
10
|
+
|
|
11
|
+
const table = (recapTitle: string, steps: Record<string, string>, seconds: Record<string, string>): Strings => ({
|
|
12
|
+
recapTitle,
|
|
13
|
+
stepsLabel: steps as Strings['stepsLabel'],
|
|
14
|
+
secondsLabel: seconds as Strings['secondsLabel'],
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const LANGUAGE_STRINGS: Record<string, Strings> = {
|
|
18
|
+
en: table('In short', { one: 'step', other: 'steps' }, { one: 'second', other: 'seconds' }),
|
|
19
|
+
ro: table('Pe scurt', { one: 'pas', few: 'pași', other: 'de pași' }, { one: 'secundă', few: 'secunde', other: 'de secunde' }),
|
|
20
|
+
de: table('Kurz gesagt', { one: 'Schritt', other: 'Schritte' }, { one: 'Sekunde', other: 'Sekunden' }),
|
|
21
|
+
fr: table('En bref', { one: 'étape', other: 'étapes' }, { one: 'seconde', other: 'secondes' }),
|
|
22
|
+
es: table('En resumen', { one: 'paso', other: 'pasos' }, { one: 'segundo', other: 'segundos' }),
|
|
23
|
+
it: table('In breve', { one: 'passaggio', other: 'passaggi' }, { one: 'secondo', other: 'secondi' }),
|
|
24
|
+
pt: table('Em resumo', { one: 'passo', other: 'passos' }, { one: 'segundo', other: 'segundos' }),
|
|
25
|
+
nl: table('Kort gezegd', { one: 'stap', other: 'stappen' }, { one: 'seconde', other: 'seconden' }),
|
|
26
|
+
pl: table('W skrócie', { one: 'krok', few: 'kroki', many: 'kroków', other: 'kroku' }, { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekundy' }),
|
|
27
|
+
ru: table('Коротко', { one: 'шаг', few: 'шага', many: 'шагов', other: 'шага' }, { one: 'секунда', few: 'секунды', many: 'секунд', other: 'секунды' }),
|
|
28
|
+
uk: table('Коротко', { one: 'крок', few: 'кроки', many: 'кроків', other: 'кроку' }, { one: 'секунда', few: 'секунди', many: 'секунд', other: 'секунди' }),
|
|
29
|
+
cs: table('Ve zkratce', { one: 'krok', few: 'kroky', many: 'kroku', other: 'kroků' }, { one: 'sekunda', few: 'sekundy', many: 'sekundy', other: 'sekund' }),
|
|
30
|
+
sv: table('I korthet', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
|
|
31
|
+
da: table('Kort fortalt', { one: 'trin', other: 'trin' }, { one: 'sekund', other: 'sekunder' }),
|
|
32
|
+
nb: table('Kort oppsummert', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
|
|
33
|
+
fi: table('Lyhyesti', { one: 'vaihe', other: 'vaihetta' }, { one: 'sekunti', other: 'sekuntia' }),
|
|
34
|
+
hu: table('Röviden', { one: 'lépés', other: 'lépés' }, { one: 'másodperc', other: 'másodperc' }),
|
|
35
|
+
tr: table('Kısaca', { one: 'adım', other: 'adım' }, { one: 'saniye', other: 'saniye' }),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The built-in strings for `language` ("pt-BR" falls back to "pt"), or English. */
|
|
39
|
+
export function stringsFor(language: string): Strings {
|
|
40
|
+
return LANGUAGE_STRINGS[language] ?? LANGUAGE_STRINGS[language.split('-')[0]] ?? LANGUAGE_STRINGS.en
|
|
41
|
+
}
|