@things-factory/figure-ui 10.1.271 → 10.1.272
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/client/modeller/canvas.ts +4 -2
- package/client/modeller/clips.ts +74 -4
- package/client/modeller/fasten-form.ts +69 -4
- package/client/modeller/figure-cavity-drawing.ts +1 -3
- package/client/modeller/figure-clips.ts +101 -8
- package/client/modeller/figure-fasten-dialog.ts +41 -30
- package/client/modeller/figure-fasten-drawing.ts +3 -1
- package/client/modeller/figure-first-save.ts +4 -3
- package/client/modeller/figure-links.ts +85 -28
- package/client/modeller/figure-preview.ts +41 -3
- package/client/modeller/figure-size-editor.ts +7 -2
- package/client/modeller/first-save.ts +8 -0
- package/client/modeller/focus-on-render.ts +4 -0
- package/client/modeller/pivot.ts +76 -0
- package/client/modeller/size-response.ts +14 -0
- package/client/modeller/stage-model.ts +50 -6
- package/dist-client/modeller/canvas.d.ts +1 -0
- package/dist-client/modeller/canvas.js +3 -2
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +21 -4
- package/dist-client/modeller/clips.js +69 -4
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +8 -0
- package/dist-client/modeller/fasten-form.js +64 -5
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/figure-cavity-drawing.js +1 -3
- package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +9 -0
- package/dist-client/modeller/figure-clips.js +104 -8
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-fasten-dialog.js +39 -24
- package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
- package/dist-client/modeller/figure-fasten-drawing.js +3 -1
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
- package/dist-client/modeller/figure-first-save.js +4 -3
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -0
- package/dist-client/modeller/figure-links.js +89 -35
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-preview.js +39 -3
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-size-editor.js +7 -3
- package/dist-client/modeller/figure-size-editor.js.map +1 -1
- package/dist-client/modeller/first-save.d.ts +5 -0
- package/dist-client/modeller/first-save.js +7 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/focus-on-render.d.ts +2 -0
- package/dist-client/modeller/focus-on-render.js +4 -0
- package/dist-client/modeller/focus-on-render.js.map +1 -0
- package/dist-client/modeller/pivot.d.ts +12 -0
- package/dist-client/modeller/pivot.js +59 -0
- package/dist-client/modeller/pivot.js.map +1 -0
- package/dist-client/modeller/size-response.d.ts +6 -0
- package/dist-client/modeller/size-response.js +14 -0
- package/dist-client/modeller/size-response.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +17 -3
- package/dist-client/modeller/stage-model.js +44 -6
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/browser/editing.check.mjs +6 -5
- package/test/browser/section.check.mjs +21 -0
- package/test/clips.test.ts +49 -4
- package/test/fasten-form.test.ts +44 -0
- package/test/first-save.test.ts +11 -1
- package/test/pivot.test.ts +57 -0
- package/test/size-response.test.ts +10 -2
- package/test/stage-model.test.ts +23 -1
- package/translations/en.json +8 -0
- package/translations/es.json +8 -0
- package/translations/ja.json +8 -0
- package/translations/ko.json +8 -0
- package/translations/ms.json +8 -0
- package/translations/zh.json +8 -0
- package/tutorial/humanoid/README.md +22 -0
- package/tutorial/humanoid/chapters.mjs +247 -0
- package/tutorial/humanoid/encode.mjs +26 -0
- package/tutorial/humanoid/lib.mjs +197 -0
- package/tutorial/humanoid/record.mjs +65 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Humanoid walk/run tutorial — recorder
|
|
2
|
+
|
|
3
|
+
The lesson itself lives in operato-figure: `helps/figure/tutorial-humanoid(.ko).html`, with its videos in
|
|
4
|
+
`helps/figure/media/humanoid/`. This folder re-records those videos whenever the modeller's screens change.
|
|
5
|
+
|
|
6
|
+
- `chapters.mjs` — the eight chapters, one per section of the lesson, in the order a person works. Values here and in
|
|
7
|
+
the lesson's tables must stay the same.
|
|
8
|
+
- `lib.mjs` — the "hands": finds a button or field by the words on the screen, moves a drawn pointer there and clicks
|
|
9
|
+
or types with the real mouse and keyboard. Nothing reaches into the editor's state.
|
|
10
|
+
- `record.mjs` — opens the modeller and plays the chapters, one WebM per chapter plus its last screen as PNG.
|
|
11
|
+
- `encode.mjs` — WebM → MP4 (H.264) and PNG → poster JPG for the lesson.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. a modeller page that opens an empty figure in Korean (the recordings show the Korean interface)
|
|
15
|
+
# 2. a dry run without video: every chapter must pass
|
|
16
|
+
node tutorial/humanoid/record.mjs --url http://localhost:5179/?fresh --out ./recordings --no-video
|
|
17
|
+
# 3. record at lesson pace, then encode into the lesson's media folder
|
|
18
|
+
node tutorial/humanoid/record.mjs --url http://localhost:5179/?fresh --out ./recordings --pace 1.2
|
|
19
|
+
node tutorial/humanoid/encode.mjs ./recordings ../../../operato-application/packages/operato-figure/helps/figure/media/humanoid
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A chapter that fails leaves `<id>-failed.png` showing where the hands stopped. ffmpeg must be on the PATH.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 휴머노이드 걷기·뛰기 튜토리얼의 장(章)들 -- 교재(operato-figure helps의 tutorial-humanoid)의 절과 하나씩 맞물린다.
|
|
3
|
+
* 한 장은 사람이 하는 그대로의 순서다: 부품을 고르고, 이름과 크기를 넣고, 붙이고, 관절을 넣고, 자세를 기록한다.
|
|
4
|
+
* 장은 앞 장이 끝난 모습에서 시작하므로 처음부터 차례로 실행한다.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* Where a part's fastening dialog is: the attach link of a part not yet fastened, or the change link of one that is. */
|
|
8
|
+
const openAttach = h => h.click('「다른 부품에 붙이기」 or 「바꾸기」', t => t.byWords(['button', 'a'], '다른 부품에 붙이기')[0] || t.byWords(['button', 'a'], '바꾸기')[0])
|
|
9
|
+
const inDialog = (h, words) => h.click(`dialog 「${words}」`, (t, w) => t.byWords(['button', 'span', 'label', 'div', 'text'], w).filter(e => t.inDialog(e) && t.words(e) === w).pop(), words)
|
|
10
|
+
const apply = h => h.click('the dialog\'s 「붙이기」/「적용」', t => t.query('md-filled-button').filter(b => ['붙이기', '적용'].includes(t.words(b))).pop())
|
|
11
|
+
|
|
12
|
+
async function addPart(h, shape, name, size) {
|
|
13
|
+
await h.buttonTitled(shape)
|
|
14
|
+
await h.tab('속성')
|
|
15
|
+
await h.field('이름', name)
|
|
16
|
+
await h.tab('크기')
|
|
17
|
+
if (shape === '구') {
|
|
18
|
+
await h.field('X·Z · 지름', size[0])
|
|
19
|
+
await h.field('Y · 지름', size[1])
|
|
20
|
+
} else {
|
|
21
|
+
await h.field('X', size[0])
|
|
22
|
+
await h.field('Y', size[1])
|
|
23
|
+
await h.field('Z', size[2])
|
|
24
|
+
}
|
|
25
|
+
await h.tab('속성')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function attach(h, target, face) {
|
|
29
|
+
await openAttach(h)
|
|
30
|
+
await inDialog(h, target)
|
|
31
|
+
if (face) await inDialog(h, face)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function joint(h, name, min, max, pivot) {
|
|
35
|
+
await h.tab('움직임')
|
|
36
|
+
await h.field('조작 이름', name)
|
|
37
|
+
await h.typeInto('range min', t => t.fieldAfter('범위 (deg)', 0) || t.fieldAfter('범위 (도)', 0), min)
|
|
38
|
+
await h.typeInto('range max', t => t.fieldAfter('범위 (deg)', 1) || t.fieldAfter('범위 (도)', 1), max)
|
|
39
|
+
/* 회전 중심을 이름으로: 위 끝 · 가운데 · 아래 끝 -- 숫자는 버튼이 채운다. */
|
|
40
|
+
if (pivot) await h.click(`pivot ${pivot}`, (t, p) => t.query(`md-filter-chip[data-value="${p}"]`)[0], pivot)
|
|
41
|
+
await h.button('움직임 추가')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* A pose, on the pose bar right under the controls: the clip chosen there, its moment typed, the eight controls typed,
|
|
46
|
+
* then 「현재 자세 기록」 -- no scrolling down to the clip.
|
|
47
|
+
*/
|
|
48
|
+
async function pose(h, clip, seconds, values) {
|
|
49
|
+
const bar = 'div[pose-bar]'
|
|
50
|
+
const chosen = await h.page.evaluate((b, c) => {
|
|
51
|
+
const t = window.__tut
|
|
52
|
+
const choice = t.all().find(e => e.matches && e.matches(`${b} span[clip-choices] button`) && t.words(e) === c)
|
|
53
|
+
return !choice || choice.getAttribute('aria-pressed') === 'true'
|
|
54
|
+
}, bar, clip)
|
|
55
|
+
if (!chosen) await h.click(`pose bar ${clip}`, (t, b, c) => t.all().find(e => e.matches && e.matches(`${b} span[clip-choices] button`) && t.words(e) === c && t.visible(e)), bar, clip)
|
|
56
|
+
await h.typeInto(`${clip} time`, (t, b) => t.bring(`${b} input[type=number]`), seconds, bar)
|
|
57
|
+
for (const [control, value] of Object.entries(values))
|
|
58
|
+
await h.typeInto(`control ${control}`, (t, c) => t.bring(`input[type=number][aria-label="${c}"]`), value, control)
|
|
59
|
+
await h.click(`${clip} 「현재 자세 기록」`, (t, b) => t.bring(`${b} button[remember-pose]`), bar)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const chapters = [
|
|
63
|
+
{
|
|
64
|
+
id: '01-pelvis',
|
|
65
|
+
title: '1. 시작 — 골반',
|
|
66
|
+
note: '바닥에 서는 피규어를 만들고, 몸의 중심인 골반을 880 mm 띄웁니다',
|
|
67
|
+
async run(h) {
|
|
68
|
+
await h.say('어디에 두는 설비인지 고릅니다', '사람은 바닥에 섭니다')
|
|
69
|
+
await h.click('start 바닥', t => t.byWords(['button'], '바닥')[0])
|
|
70
|
+
await h.say('첫 부품으로 상자를 놓습니다')
|
|
71
|
+
await h.click('start box', t => t.query('button[start-shape="box"]')[0])
|
|
72
|
+
await h.say('이름을 pelvis로 바꿉니다', '이름은 나중에 관절·데이터 연결에 그대로 쓰입니다')
|
|
73
|
+
await h.field('이름', 'pelvis')
|
|
74
|
+
await h.say('크기 탭에서 300 × 180 × 200 mm', 'Y가 위쪽입니다')
|
|
75
|
+
await h.tab('크기')
|
|
76
|
+
await h.field('X', 300)
|
|
77
|
+
await h.field('Y', 180)
|
|
78
|
+
await h.field('Z', 200)
|
|
79
|
+
await h.tab('속성')
|
|
80
|
+
await h.say('「바꾸기」 → 세부 → 간격 880 mm', '다리가 들어갈 자리만큼 바닥에서 띄웁니다')
|
|
81
|
+
await h.button('바꾸기')
|
|
82
|
+
await h.click('세부', t => t.byWords(['summary'], '세부')[0] || t.byWords(['summary'], '세부 · ').pop())
|
|
83
|
+
await h.typeInDialog('gap', t => t.query('input[gap]')[0], 880)
|
|
84
|
+
await apply(h)
|
|
85
|
+
await h.say('')
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: '02-body',
|
|
90
|
+
title: '2. 몸통과 머리',
|
|
91
|
+
note: '「다른 부품에 붙이기」로 부품을 쌓습니다',
|
|
92
|
+
async run(h) {
|
|
93
|
+
await h.say('상자를 하나 더 놓고 torso라고 이름 붙입니다', '340 × 500 × 200 mm')
|
|
94
|
+
await addPart(h, '상자', 'torso', [340, 500, 200])
|
|
95
|
+
await h.say('pelvis에 붙입니다', '비어 있는 윗면 가운데가 기본입니다')
|
|
96
|
+
await attach(h, 'pelvis')
|
|
97
|
+
await apply(h)
|
|
98
|
+
await h.say('구를 놓고 head — 지름 200 × 240', '구는 가로(X·Z) 지름과 세로(Y) 지름 두 가지로 정합니다')
|
|
99
|
+
await addPart(h, '구', 'head', [200, 240])
|
|
100
|
+
await attach(h, 'torso')
|
|
101
|
+
await apply(h)
|
|
102
|
+
await h.say('')
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: '03-left-leg',
|
|
107
|
+
title: '3. 왼쪽 다리와 관절',
|
|
108
|
+
note: '허벅지 → 정강이 → 발, 고관절과 무릎',
|
|
109
|
+
async run(h) {
|
|
110
|
+
await h.say('l-thigh: 130 × 420 × 140', '왼쪽(−X) 부품은 l- 로 시작합니다 — 대칭 복사가 r- 로 바꿔 줍니다')
|
|
111
|
+
await addPart(h, '상자', 'l-thigh', [130, 420, 140])
|
|
112
|
+
await h.say('pelvis에 붙이면 비어 있는 아랫면이 기본입니다')
|
|
113
|
+
await attach(h, 'pelvis')
|
|
114
|
+
await h.say('세부 → 좌우 위치 「왼쪽 끝 정렬」', '골반의 왼쪽 가장자리에 맞춥니다')
|
|
115
|
+
await h.click('세부', t => t.byWords(['summary'], '세부')[0] || t.query('details[fasten-detail] summary')[0])
|
|
116
|
+
await h.click('왼쪽 끝 정렬', t => t.byWords(['button'], '왼쪽 끝 정렬')[0])
|
|
117
|
+
await apply(h)
|
|
118
|
+
await h.say('움직임 탭: 회전 · 축 X, 이름 l-hip, −60 ~ 60°', '다른 부품에 붙은 부품은 「회전」이 기본입니다')
|
|
119
|
+
await joint(h, 'l-hip', -60, 60)
|
|
120
|
+
await h.say('l-shin: 110 × 420 × 120 → l-thigh 아랫면')
|
|
121
|
+
await addPart(h, '상자', 'l-shin', [110, 420, 120])
|
|
122
|
+
await attach(h, 'l-thigh')
|
|
123
|
+
await apply(h)
|
|
124
|
+
await h.say('무릎 l-knee: 0 ~ 130°', '+ 가 뒤로 굽는 방향입니다 (반투명 미리보기로 확인)')
|
|
125
|
+
await joint(h, 'l-knee', 0, 130)
|
|
126
|
+
await h.say('l-foot: 110 × 70 × 240 → l-shin 아랫면')
|
|
127
|
+
await addPart(h, '상자', 'l-foot', [110, 70, 240])
|
|
128
|
+
await attach(h, 'l-shin')
|
|
129
|
+
await apply(h)
|
|
130
|
+
await h.say('')
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: '04-left-arm',
|
|
135
|
+
title: '4. 왼팔과 관절',
|
|
136
|
+
note: '옆면에 붙이고 위 끝에 맞추기, 회전 중심 옮기기',
|
|
137
|
+
async run(h) {
|
|
138
|
+
await h.say('l-upperarm: 90 × 300 × 90')
|
|
139
|
+
await addPart(h, '상자', 'l-upperarm', [90, 300, 90])
|
|
140
|
+
await h.say('torso에 붙이면 비어 있는 「왼쪽 면」이 기본입니다')
|
|
141
|
+
await attach(h, 'torso')
|
|
142
|
+
await h.say('세부 → 위아래 위치 「위 끝 정렬」', '어깨 높이에 맞춥니다')
|
|
143
|
+
await h.click('세부', t => t.byWords(['summary'], '세부')[0] || t.query('details[fasten-detail] summary')[0])
|
|
144
|
+
await h.click('위 끝 정렬', t => t.byWords(['button'], '위 끝 정렬')[0])
|
|
145
|
+
await apply(h)
|
|
146
|
+
await h.say('어깨 l-shoulder: −60 ~ 60°, 회전 중심 「위 끝」', '위 끝 정렬로 붙였으니 이미 위 끝이 골라져 있습니다 — 숫자는 버튼이 채웁니다')
|
|
147
|
+
await joint(h, 'l-shoulder', -60, 60, 'top')
|
|
148
|
+
await h.say('l-forearm: 80 × 280 × 80 → l-upperarm', '세로로 긴 팔에 붙이면 아랫면이 기본입니다')
|
|
149
|
+
await addPart(h, '상자', 'l-forearm', [80, 280, 80])
|
|
150
|
+
await attach(h, 'l-upperarm')
|
|
151
|
+
await apply(h)
|
|
152
|
+
await h.say('팔꿈치 l-elbow: −130 ~ 0°', '− 가 앞으로 굽는 방향입니다')
|
|
153
|
+
await joint(h, 'l-elbow', -130, 0)
|
|
154
|
+
await h.say('')
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: '05-mirror',
|
|
159
|
+
title: '5. 대칭 복사로 오른쪽',
|
|
160
|
+
note: '아래 붙은 부품과 관절까지 한 번에',
|
|
161
|
+
async run(h) {
|
|
162
|
+
for (const part of ['l-thigh', 'l-upperarm']) {
|
|
163
|
+
await h.say(`${part} 선택 → 속성 → 더 보기 → 「X 반대편에」`, '이름 앞머리 l- → r- 가 자동으로 채워집니다')
|
|
164
|
+
await h.click(`list ${part}`, (t, p) => t.all().filter(e => t.visible(e) && e.getBoundingClientRect().left < 220 && [...e.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim() === p)[0], part)
|
|
165
|
+
await h.tab('속성')
|
|
166
|
+
await h.click('더 보기', t => t.byWords(['summary', 'button', 'span'], '더 보기')[0])
|
|
167
|
+
await h.click('X 반대편에', t => t.byWords(['button'], 'X 반대편에')[0])
|
|
168
|
+
await h.say('「움직임 방향 유지」를 켜면 좌우 관절의 + 방향이 같아집니다', '걷기 자세를 좌우 같은 숫자 규칙으로 넣을 수 있습니다')
|
|
169
|
+
await h.click('움직임 방향 유지', t => t.byWords(['label'], '움직임 방향 유지')[0])
|
|
170
|
+
await h.click('X 반대편에 (confirm)', t => t.byWords(['button'], 'X 반대편에').pop())
|
|
171
|
+
}
|
|
172
|
+
await h.page.keyboard.press('Escape')
|
|
173
|
+
await h.say('정면 뷰로 확인합니다')
|
|
174
|
+
await h.click('Front view', t => t.all().find(e => e.getAttribute && e.getAttribute('title') === 'Front' && t.visible(e)))
|
|
175
|
+
await h.wait(1500)
|
|
176
|
+
await h.click('Perspective view', t => t.all().find(e => e.getAttribute && e.getAttribute('title') === 'Perspective' && t.visible(e)))
|
|
177
|
+
await h.say('')
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: '06-walk',
|
|
182
|
+
title: '6. 걷기 — 자세 4개 기록',
|
|
183
|
+
note: '시간을 옮기고, 조작값을 넣고, 「현재 자세 기록」',
|
|
184
|
+
async run(h) {
|
|
185
|
+
await h.page.keyboard.press('Escape')
|
|
186
|
+
await h.tab('움직임')
|
|
187
|
+
await h.say('클립 이름 walk, 길이 1.1초 → 「재생 추가」', '첫 트랙 하나만 만들면 나머지는 자세 기록이 만들어 줍니다')
|
|
188
|
+
await h.field('clip 이름', 'walk')
|
|
189
|
+
await h.field('길이 (초)', 1.1)
|
|
190
|
+
await h.button('재생 추가')
|
|
191
|
+
const walk = [
|
|
192
|
+
[0, { 'l-hip': -25, 'r-hip': 15, 'l-knee': 5, 'r-knee': 20, 'l-shoulder': 20, 'r-shoulder': -20, 'l-elbow': -15, 'r-elbow': -30 }, '0초 — 왼발 내딛기'],
|
|
193
|
+
[0.275, { 'l-hip': -5, 'r-hip': -10, 'l-knee': 10, 'r-knee': 55, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -20, 'r-elbow': -20 }, '0.275초 — 오른발 지나가기'],
|
|
194
|
+
[0.55, { 'l-hip': 15, 'r-hip': -25, 'l-knee': 20, 'r-knee': 5, 'l-shoulder': -20, 'r-shoulder': 20, 'l-elbow': -30, 'r-elbow': -15 }, '0.55초 — 오른발 내딛기'],
|
|
195
|
+
[0.825, { 'l-hip': -10, 'r-hip': -5, 'l-knee': 55, 'r-knee': 10, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -20, 'r-elbow': -20 }, '0.825초 — 왼발 지나가기']
|
|
196
|
+
]
|
|
197
|
+
for (const [at, values, words] of walk) {
|
|
198
|
+
await h.say(words, '고관절 − 는 앞으로, 무릎 + 는 뒤로 굽힘. 1.1초(끝)는 0초와 같게 자동으로 맞춰집니다')
|
|
199
|
+
await pose(h, 'walk', at, values)
|
|
200
|
+
}
|
|
201
|
+
await h.say('')
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: '07-run',
|
|
206
|
+
title: '7. 뛰기 — 클립 복사 후 자세 바꾸기',
|
|
207
|
+
note: '복사하면 걷기·뛰기가 한 동작 그룹이 됩니다',
|
|
208
|
+
async run(h) {
|
|
209
|
+
await h.say('walk의 「클립 복사」', '복사본과 함께 동작 그룹이 생깁니다 — 한 번에 하나만 재생')
|
|
210
|
+
await h.click('copy walk', t => { const top = t.clipTop('walk'); return t.query('button[copy-clip]').find(b => b.getBoundingClientRect().top > top) })
|
|
211
|
+
await h.say('이름 run, 길이 0.7초', '키 시각은 길이에 맞춰 함께 줄어듭니다')
|
|
212
|
+
await h.typeInto('walk-copy name', t => { t.clipTop('walk-copy'); return t.all().find(e => e.tagName === 'INPUT' && e.value === 'walk-copy') }, 'run')
|
|
213
|
+
await h.typeInto('run length', t => { const top = t.clipTop('run'); return t.fieldAfter('길이 (초)', 0, { after: top - 5 }) }, 0.7)
|
|
214
|
+
const run = [
|
|
215
|
+
[0, { 'l-hip': -40, 'r-hip': 25, 'l-knee': 20, 'r-knee': 75, 'l-shoulder': 40, 'r-shoulder': -45, 'l-elbow': -90, 'r-elbow': -95 }, '0초 — 왼발 착지, 팔은 90° 굽혀 크게'],
|
|
216
|
+
[0.175, { 'l-hip': -5, 'r-hip': -45, 'l-knee': 35, 'r-knee': 110, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -90, 'r-elbow': -90 }, '0.175초 — 오른 무릎 높이 들기'],
|
|
217
|
+
[0.35, { 'l-hip': 25, 'r-hip': -40, 'l-knee': 75, 'r-knee': 20, 'l-shoulder': -45, 'r-shoulder': 40, 'l-elbow': -95, 'r-elbow': -90 }, '0.35초 — 오른발 착지'],
|
|
218
|
+
[0.525, { 'l-hip': -45, 'r-hip': -5, 'l-knee': 110, 'r-knee': 35, 'l-shoulder': 0, 'r-shoulder': 0, 'l-elbow': -90, 'r-elbow': -90 }, '0.525초 — 왼 무릎 높이 들기']
|
|
219
|
+
]
|
|
220
|
+
for (const [at, values, words] of run) {
|
|
221
|
+
await h.say(words)
|
|
222
|
+
await pose(h, 'run', at, values)
|
|
223
|
+
}
|
|
224
|
+
await h.say('')
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: '08-preview',
|
|
229
|
+
title: '8. 미리보기에서 재생',
|
|
230
|
+
note: '동작 그룹에서 클립을 고르고 배속을 올립니다',
|
|
231
|
+
async run(h) {
|
|
232
|
+
await h.button('미리보기')
|
|
233
|
+
await h.wait(1500)
|
|
234
|
+
await h.say('「동작」 그룹: 클립 walk, 배속 1', '복사로 만든 그룹은 「동작」이라는 이름으로 생깁니다')
|
|
235
|
+
await h.click('walk clip', t => t.query('span[clip-choices] button').find(b => t.words(b) === 'walk'))
|
|
236
|
+
const speed = await h.locate('speed', t => t.query('input[type=range][max="3"]').pop())
|
|
237
|
+
await h.drag({ x: speed.x - speed.w / 2 + 2, y: speed.y }, { x: speed.x - speed.w / 2 + speed.w / 3, y: speed.y })
|
|
238
|
+
await h.wait(4000)
|
|
239
|
+
await h.say('클립 「run」 — 같은 배속으로 바뀌어 뜁니다', '조작값 줄은 「재생 중」으로 잠깁니다')
|
|
240
|
+
await h.click('run clip', t => t.query('span[clip-choices] button').find(b => t.words(b) === 'run'))
|
|
241
|
+
await h.wait(4000)
|
|
242
|
+
await h.say('')
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
export { addPart, attach, apply, joint, pose, openAttach, inDialog }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 녹화한 장(章) 영상을 교재에 싣는 형태로 바꾼다: WebM → MP4(H.264, 웹 재생용 faststart), 장의 마지막 화면 → 포스터 JPG.
|
|
3
|
+
*
|
|
4
|
+
* node tutorial/humanoid/encode.mjs <녹화 폴더> <교재 media 폴더>
|
|
5
|
+
*
|
|
6
|
+
* 교재 media 폴더는 operato-figure 의 helps/figure/media/humanoid 이다. ffmpeg 이 PATH 에 있어야 한다.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process'
|
|
9
|
+
import { mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const [from, to] = process.argv.slice(2).map(p => p && resolve(p))
|
|
13
|
+
if (!from || !to) {
|
|
14
|
+
console.error('usage: node tutorial/humanoid/encode.mjs <recordings> <media folder>')
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
mkdirSync(to, { recursive: true })
|
|
18
|
+
|
|
19
|
+
for (const name of readdirSync(from).filter(n => n.endsWith('.webm')).sort()) {
|
|
20
|
+
const id = name.slice(0, -5)
|
|
21
|
+
const mp4 = join(to, `${id}.mp4`)
|
|
22
|
+
/* 1280 wide keeps the panels' words legible; crf 28 and no sound keep a chapter to a few MB. */
|
|
23
|
+
execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', join(from, name), '-vf', 'scale=1280:-2,fps=30', '-c:v', 'libx264', '-preset', 'slow', '-crf', '28', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', mp4])
|
|
24
|
+
execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', join(from, `${id}.png`), '-vf', 'scale=1280:-2', '-q:v', '4', join(to, `${id}.jpg`)])
|
|
25
|
+
console.log(`${id}.mp4 ${(statSync(mp4).size / 1e6).toFixed(1)} MB`)
|
|
26
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The tutorial recorder's hands: it finds what a person would look for on the screen -- a button by its words or its
|
|
3
|
+
* title, a field by the words beside it -- and moves a drawn pointer there before it clicks or types, so a recording
|
|
4
|
+
* shows where the hand went. Nothing here reaches into the editor's state: every step is a click, a key or a drag.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Everything on the page, shadow roots included, as the page's own function (run in the page). */
|
|
8
|
+
export const PAGE_HELPERS = `
|
|
9
|
+
window.__tut = {
|
|
10
|
+
all() {
|
|
11
|
+
const out = []
|
|
12
|
+
const walk = root => root.querySelectorAll('*').forEach(e => { out.push(e); if (e.shadowRoot) walk(e.shadowRoot) })
|
|
13
|
+
walk(document)
|
|
14
|
+
return out
|
|
15
|
+
},
|
|
16
|
+
visible(e) {
|
|
17
|
+
const b = e.getBoundingClientRect()
|
|
18
|
+
if (b.width <= 0 || b.height <= 0) return false
|
|
19
|
+
let n = e
|
|
20
|
+
while (n) {
|
|
21
|
+
if (n instanceof Element && getComputedStyle(n).visibility === 'hidden') return false
|
|
22
|
+
n = n.parentElement || (n.getRootNode && n.getRootNode().host)
|
|
23
|
+
}
|
|
24
|
+
return b.bottom > 0 && b.right > 0 && b.top < innerHeight && b.left < innerWidth
|
|
25
|
+
},
|
|
26
|
+
words(e) { return (e.textContent || '').replace(/\\s+/g, ' ').trim() },
|
|
27
|
+
/* Elements matching a CSS selector, visible, in page order. */
|
|
28
|
+
query(selector) { return this.all().filter(e => e.matches && e.matches(selector) && this.visible(e)) },
|
|
29
|
+
/* The visible element of the given tags whose words are exactly, or end with, the given words (icons come first). */
|
|
30
|
+
byWords(tags, words, { within } = {}) {
|
|
31
|
+
const box = within ? within.getBoundingClientRect() : null
|
|
32
|
+
return this.all().filter(e => tags.includes(e.tagName.toLowerCase()) && this.visible(e)).filter(e => {
|
|
33
|
+
const w = this.words(e)
|
|
34
|
+
if (!(w === words || w.endsWith(words))) return false
|
|
35
|
+
if (!box) return true
|
|
36
|
+
const b = e.getBoundingClientRect()
|
|
37
|
+
return b.top >= box.top - 1 && b.bottom <= box.bottom + 1
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
/* The field that stands after the given words: the first input or select after a label-like element with them. */
|
|
41
|
+
fieldAfter(words, nth = 0, { after } = {}) {
|
|
42
|
+
const all = this.all()
|
|
43
|
+
const labels = all.filter(e => ['span', 'label', 'strong', 'div'].includes(e.tagName.toLowerCase()) && e.children.length <= 1 && this.words(e) === words && this.visible(e))
|
|
44
|
+
.filter(e => !after || e.getBoundingClientRect().top > after)
|
|
45
|
+
for (const label of labels) {
|
|
46
|
+
const i = all.indexOf(label)
|
|
47
|
+
const fields = all.slice(i + 1).filter(e => ['input', 'select'].includes(e.tagName.toLowerCase()) && this.visible(e))
|
|
48
|
+
if (fields[nth]) return fields[nth]
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
},
|
|
52
|
+
/* The first element matching a selector that has a size, scrolled to the middle of its pane when it stands out of view. */
|
|
53
|
+
bring(selector) {
|
|
54
|
+
const e = this.all().find(e => e.matches && e.matches(selector) && e.getBoundingClientRect().width > 0)
|
|
55
|
+
if (e && !this.visible(e)) e.scrollIntoView({ block: 'center' })
|
|
56
|
+
return e || null
|
|
57
|
+
},
|
|
58
|
+
/* A clip's section, brought into view by its name: its time and 「현재 자세 기록」 stand under it. */
|
|
59
|
+
clipTop(clip) {
|
|
60
|
+
const name = this.all().find(e => e.tagName === 'INPUT' && e.type !== 'number' && e.value === clip)
|
|
61
|
+
if (!name) return -1
|
|
62
|
+
name.scrollIntoView({ block: 'center' })
|
|
63
|
+
return name.getBoundingClientRect().top
|
|
64
|
+
},
|
|
65
|
+
/* Whether an element stands in the fastening dialog (its drawing, its chips, its buttons). */
|
|
66
|
+
inDialog(e) {
|
|
67
|
+
for (let n = e; n; n = n.parentElement || (n.getRootNode && n.getRootNode().host))
|
|
68
|
+
if (n.tagName === 'FIGURE-FASTEN-DIALOG') return true
|
|
69
|
+
return false
|
|
70
|
+
},
|
|
71
|
+
centre(e) {
|
|
72
|
+
if (!e) return null
|
|
73
|
+
e.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
74
|
+
const b = e.getBoundingClientRect()
|
|
75
|
+
return { x: b.left + b.width / 2, y: b.top + b.height / 2, w: b.width, h: b.height }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`
|
|
79
|
+
|
|
80
|
+
/** The drawn pointer, the ring of a click and the caption under the screen: what a recording needs to be followed. */
|
|
81
|
+
export const OVERLAY = `
|
|
82
|
+
(() => {
|
|
83
|
+
if (document.getElementById('tut-overlay')) return
|
|
84
|
+
const style = document.createElement('style')
|
|
85
|
+
style.textContent = \`
|
|
86
|
+
#tut-pointer { position: fixed; z-index: 2147483647; width: 22px; height: 22px; margin: -2px 0 0 -2px; pointer-events: none;
|
|
87
|
+
background: no-repeat center/contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 2l7 19 2.6-7.4L20 11z' fill='%23111' stroke='%23fff' stroke-width='1.6' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
88
|
+
transition: left .35s ease, top .35s ease; left: 50%; top: 50% }
|
|
89
|
+
.tut-ring { position: fixed; z-index: 2147483646; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%;
|
|
90
|
+
border: 3px solid #0d8f86; pointer-events: none; animation: tut-ring .6s ease-out forwards }
|
|
91
|
+
@keyframes tut-ring { from { transform: scale(.4); opacity: 1 } to { transform: scale(1.4); opacity: 0 } }
|
|
92
|
+
#tut-caption { position: fixed; z-index: 2147483645; left: 50%; bottom: 18px; transform: translateX(-50%); max-width: 70%;
|
|
93
|
+
padding: 10px 18px; border-radius: 10px; background: rgba(16, 32, 43, .88); color: #fff; font: 600 17px/1.45 -apple-system, 'Apple SD Gothic Neo', sans-serif;
|
|
94
|
+
text-align: center; pointer-events: none; box-shadow: 0 6px 20px rgba(0,0,0,.25) }
|
|
95
|
+
#tut-caption:empty { display: none }
|
|
96
|
+
#tut-caption small { display: block; font-weight: 500; font-size: 13px; opacity: .8 }
|
|
97
|
+
#tut-chapter { position: fixed; z-index: 2147483645; left: 50%; top: 40%; transform: translate(-50%, -50%); padding: 22px 34px;
|
|
98
|
+
border-radius: 14px; background: rgba(16, 32, 43, .92); color: #fff; font: 700 28px/1.4 -apple-system, 'Apple SD Gothic Neo', sans-serif;
|
|
99
|
+
text-align: center; pointer-events: none }
|
|
100
|
+
#tut-chapter small { display: block; font-size: 16px; font-weight: 500; opacity: .85; margin-top: 6px }
|
|
101
|
+
#tut-chapter:empty { display: none }
|
|
102
|
+
\`
|
|
103
|
+
document.head.append(style)
|
|
104
|
+
const pointer = Object.assign(document.createElement('div'), { id: 'tut-pointer' })
|
|
105
|
+
const caption = Object.assign(document.createElement('div'), { id: 'tut-caption' })
|
|
106
|
+
const chapter = Object.assign(document.createElement('div'), { id: 'tut-chapter' })
|
|
107
|
+
const overlay = Object.assign(document.createElement('div'), { id: 'tut-overlay' })
|
|
108
|
+
document.body.append(overlay, pointer, caption, chapter)
|
|
109
|
+
})()
|
|
110
|
+
`
|
|
111
|
+
|
|
112
|
+
const sleep = ms => new Promise(done => setTimeout(done, ms))
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The hands, bound to a page: each step finds its target the way a person would, moves the drawn pointer to it and acts
|
|
116
|
+
* with the real mouse and keyboard. `pace` stretches every pause, so the same steps record slower for a lesson.
|
|
117
|
+
*/
|
|
118
|
+
export function handsFor(page, { pace = 1 } = {}) {
|
|
119
|
+
const wait = ms => sleep(ms * pace)
|
|
120
|
+
const say = async (text, note = '') => {
|
|
121
|
+
await page.evaluate((t, n) => {
|
|
122
|
+
const c = document.getElementById('tut-caption')
|
|
123
|
+
c.textContent = t
|
|
124
|
+
if (n) c.append(Object.assign(document.createElement('small'), { textContent: n }))
|
|
125
|
+
}, text, note)
|
|
126
|
+
}
|
|
127
|
+
const title = async (text, note = '') => {
|
|
128
|
+
await page.evaluate((t, n) => {
|
|
129
|
+
const c = document.getElementById('tut-chapter')
|
|
130
|
+
c.textContent = t
|
|
131
|
+
if (n) c.append(Object.assign(document.createElement('small'), { textContent: n }))
|
|
132
|
+
}, text, note)
|
|
133
|
+
await wait(1800)
|
|
134
|
+
await page.evaluate(() => (document.getElementById('tut-chapter').textContent = ''))
|
|
135
|
+
}
|
|
136
|
+
const point = async (x, y) => {
|
|
137
|
+
await page.evaluate((px, py) => Object.assign(document.getElementById('tut-pointer').style, { left: px + 'px', top: py + 'px' }), x, y)
|
|
138
|
+
await page.mouse.move(x, y, { steps: 8 })
|
|
139
|
+
await wait(380)
|
|
140
|
+
}
|
|
141
|
+
const ring = (x, y) => page.evaluate((px, py) => {
|
|
142
|
+
const r = Object.assign(document.createElement('div'), { className: 'tut-ring' })
|
|
143
|
+
Object.assign(r.style, { left: px + 'px', top: py + 'px' })
|
|
144
|
+
document.body.append(r)
|
|
145
|
+
setTimeout(() => r.remove(), 700)
|
|
146
|
+
}, x, y)
|
|
147
|
+
/** The centre of what `find` (run in the page, given __tut) returns, or an error naming what was looked for. */
|
|
148
|
+
/* Found and standing still: a dialog that grows as it is filled moves its buttons, and a click aimed before it settled missed. */
|
|
149
|
+
const locate = async (what, find, ...args) => {
|
|
150
|
+
let last = null
|
|
151
|
+
for (let tries = 0; tries < 30; tries++) {
|
|
152
|
+
const at = await page.evaluate(new Function('args', `const t = window.__tut; return t.centre((${find})(t, ...args))`), args)
|
|
153
|
+
if (at && last && Math.abs(at.x - last.x) < 1 && Math.abs(at.y - last.y) < 1) return at
|
|
154
|
+
last = at
|
|
155
|
+
await sleep(120)
|
|
156
|
+
}
|
|
157
|
+
if (last) return last
|
|
158
|
+
throw Error(`not found on the screen: ${what}`)
|
|
159
|
+
}
|
|
160
|
+
const clickAt = async ({ x, y }, { clicks = 1 } = {}) => {
|
|
161
|
+
await point(x, y)
|
|
162
|
+
await ring(x, y)
|
|
163
|
+
await page.mouse.click(x, y, { clickCount: clicks })
|
|
164
|
+
await wait(450)
|
|
165
|
+
}
|
|
166
|
+
const click = async (what, find, ...args) => clickAt(await locate(what, find, ...args))
|
|
167
|
+
const typeInto = async (what, find, text, ...args) => typeKeyed(what, find, text, 'Enter', ...args)
|
|
168
|
+
/* In a dialog Enter applies the dialog: a field there is left by Tab. */
|
|
169
|
+
const typeInDialog = async (what, find, text, ...args) => typeKeyed(what, find, text, 'Tab', ...args)
|
|
170
|
+
const typeKeyed = async (what, find, text, key, ...args) => {
|
|
171
|
+
const at = await locate(what, find, ...args)
|
|
172
|
+
await clickAt(at, { clicks: 3 })
|
|
173
|
+
await page.keyboard.down('Meta')
|
|
174
|
+
await page.keyboard.press('KeyA')
|
|
175
|
+
await page.keyboard.up('Meta')
|
|
176
|
+
await page.keyboard.type(String(text), { delay: 60 * pace })
|
|
177
|
+
await page.keyboard.press(key)
|
|
178
|
+
await wait(350)
|
|
179
|
+
}
|
|
180
|
+
const drag = async (from, to) => {
|
|
181
|
+
await point(from.x, from.y)
|
|
182
|
+
await page.mouse.down()
|
|
183
|
+
await page.mouse.move(to.x, to.y, { steps: 18 })
|
|
184
|
+
await page.evaluate((px, py) => Object.assign(document.getElementById('tut-pointer').style, { left: px + 'px', top: py + 'px' }), to.x, to.y)
|
|
185
|
+
await page.mouse.up()
|
|
186
|
+
await wait(500)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* What a person names on the screen. */
|
|
190
|
+
const button = words => click(`button 「${words}」`, (t, w) => t.byWords(['button'], w)[0], words)
|
|
191
|
+
const buttonTitled = title => click(`button titled 「${title}」`, (t, w) => t.query(`button[title="${w}"]`)[0], title)
|
|
192
|
+
const tab = name => click(`tab 「${name}」`, (t, w) => t.query(`button[tab][aria-label="${w}"]`)[0], name)
|
|
193
|
+
const field = (label, text, nth = 0) => typeInto(`field after 「${label}」`, (t, l, n) => t.fieldAfter(l, n), text, label, nth)
|
|
194
|
+
const chip = name => click(`chip 「${name}」`, (t, w) => t.byWords(['button', 'span', 'label'], w).find(e => e.closest('[open], dialog, ox-popup, figure-fasten-dialog') || true), name)
|
|
195
|
+
|
|
196
|
+
return { page, wait, say, title, point, click, clickAt, typeInto, typeInDialog, locate, drag, button, buttonTitled, tab, field, chip }
|
|
197
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 휴머노이드 튜토리얼 녹화기: 모델러를 열고 장(章)마다 사람이 하는 그대로 따라 하며, 장마다 한 편의 영상(WebM)을 남긴다.
|
|
3
|
+
*
|
|
4
|
+
* node tutorial/humanoid/record.mjs --url http://localhost:5179/?fresh --out ./tutorial-videos [--only 01-pelvis] [--no-video] [--pace 1.2]
|
|
5
|
+
*
|
|
6
|
+
* --url 모델러 페이지. 빈 피규어로 열리는 곳이어야 한다(로컬 개발 페이지는 ?fresh).
|
|
7
|
+
* --out 영상과 장마다 마지막 화면(PNG)을 둘 폴더.
|
|
8
|
+
* --only 한 장만(앞 장들은 영상 없이 먼저 따라 한다).
|
|
9
|
+
* --pace 쉬는 시간 배율 -- 강의용은 1.2~1.5.
|
|
10
|
+
*
|
|
11
|
+
* ffmpeg 이 PATH 에 있어야 영상이 남는다(puppeteer screencast).
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync } from 'node:fs'
|
|
14
|
+
import { join, resolve } from 'node:path'
|
|
15
|
+
import puppeteer from 'puppeteer'
|
|
16
|
+
import { handsFor, OVERLAY, PAGE_HELPERS } from './lib.mjs'
|
|
17
|
+
import { chapters } from './chapters.mjs'
|
|
18
|
+
|
|
19
|
+
const arg = (name, fallback) => {
|
|
20
|
+
const i = process.argv.indexOf(`--${name}`)
|
|
21
|
+
return i < 0 ? fallback : (process.argv[i + 1] ?? true)
|
|
22
|
+
}
|
|
23
|
+
const url = arg('url', 'http://localhost:5179/?fresh')
|
|
24
|
+
const out = resolve(arg('out', './tutorial-videos'))
|
|
25
|
+
const only = arg('only', '')
|
|
26
|
+
const video = !process.argv.includes('--no-video')
|
|
27
|
+
const pace = Number(arg('pace', 1))
|
|
28
|
+
const headed = process.argv.includes('--headed')
|
|
29
|
+
mkdirSync(out, { recursive: true })
|
|
30
|
+
|
|
31
|
+
const browser = await puppeteer.launch({ headless: !headed, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--window-size=1600,900'] })
|
|
32
|
+
const page = await browser.newPage()
|
|
33
|
+
await page.setViewport({ width: 1600, height: 900 })
|
|
34
|
+
const errors = []
|
|
35
|
+
page.on('pageerror', e => errors.push(String(e)))
|
|
36
|
+
await page.goto(url, { waitUntil: 'networkidle0' })
|
|
37
|
+
await new Promise(done => setTimeout(done, 2500))
|
|
38
|
+
await page.evaluate(PAGE_HELPERS)
|
|
39
|
+
await page.evaluate(OVERLAY)
|
|
40
|
+
const h = handsFor(page, { pace })
|
|
41
|
+
|
|
42
|
+
const index = only ? chapters.findIndex(c => c.id === only) : -1
|
|
43
|
+
for (const [i, chapter] of chapters.entries()) {
|
|
44
|
+
const recording = video && (!only || i === index)
|
|
45
|
+
if (only && i > index) break
|
|
46
|
+
const started = Date.now()
|
|
47
|
+
const recorder = recording ? await page.screencast({ path: join(out, `${chapter.id}.webm`) }) : null
|
|
48
|
+
if (recording) await h.title(chapter.title, chapter.note)
|
|
49
|
+
try {
|
|
50
|
+
await chapter.run(recording ? h : handsFor(page, { pace: 0.2 }))
|
|
51
|
+
} catch (e) {
|
|
52
|
+
await page.screenshot({ path: join(out, `${chapter.id}-failed.png`) })
|
|
53
|
+
await recorder?.stop()
|
|
54
|
+
console.error(`✗ ${chapter.id}: ${e.message}`)
|
|
55
|
+
await browser.close()
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
await page.evaluate(() => document.querySelector('figure-editor')?.renderRoot?.querySelector('button[data-action="fit"]'))
|
|
59
|
+
await new Promise(done => setTimeout(done, 600))
|
|
60
|
+
await page.screenshot({ path: join(out, `${chapter.id}.png`) })
|
|
61
|
+
await recorder?.stop()
|
|
62
|
+
console.log(`✓ ${chapter.id} ${((Date.now() - started) / 1000).toFixed(1)}s${recording ? ' (영상)' : ''}`)
|
|
63
|
+
}
|
|
64
|
+
if (errors.length) console.log('page errors:', errors.slice(0, 5).join('\n'))
|
|
65
|
+
await browser.close()
|