diff --git a/tools/web/course/check-full-course.mjs b/tools/web/course/check-full-course.mjs index bce10a45a..bbd37503b 100644 --- a/tools/web/course/check-full-course.mjs +++ b/tools/web/course/check-full-course.mjs @@ -46,6 +46,20 @@ for (const match of html.matchAll(/]+src="([^"]+)"/g)) { if (video.width !== 1280 || video.height !== 720) errors.push(`${match[1]} is not 1280x720`); } -const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", modules, videos: media.length, media, errors }; +const moduleIds = [...html.matchAll(/data-module-video="([^"]+)"/g)].map((match) => match[1]); +if (moduleIds.length !== 15) errors.push(`expected 15 module videos, found ${moduleIds.length}`); +if (new Set(moduleIds).size !== moduleIds.length) errors.push("duplicate module video ids"); +const moduleMedia = media.filter((item) => /(^|\/)video\/M\d+-lesson-/.test(item.src)); +if (moduleMedia.length !== 15) errors.push(`expected 15 module video files, found ${moduleMedia.length}`); +for (const item of moduleMedia) { + if (item.duration < 180 || item.duration > 480) errors.push(`${item.src} duration ${item.duration}s outside 180-480s`); +} +for (const moduleId of moduleIds) { + const slugPrefix = `video/${moduleId}-lesson`; + if (!html.includes(`src="${slugPrefix}-720p.mp4"`)) errors.push(`${moduleId} video src missing`); + if (!html.includes(`${slugPrefix}-poster.jpg`)) errors.push(`${moduleId} poster missing`); + if (!html.includes(`${slugPrefix}.zh-Hans.vtt`)) errors.push(`${moduleId} VTT missing`); +} +const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", modules, moduleVideos: moduleIds.length, videos: media.length, totalMediaBytes: media.reduce((total, item) => total + (item.bytes ?? 0), 0), media, errors }; console.log(JSON.stringify(result, null, 2)); if (errors.length > 0) process.exitCode = 1; diff --git a/tools/web/course/full-course.json b/tools/web/course/full-course.json index cbab77b09..ce637adc0 100644 --- a/tools/web/course/full-course.json +++ b/tools/web/course/full-course.json @@ -36,16 +36,51 @@ "level": "入门", "minutes": 6, "summary": "认识顶栏、工作区标签、三维视口、大纲视图和属性面板;打开练习工程并理解 OPFS 保存。", - "objectives": ["找到并切换工作区", "用“打开 .blend”载入练习工程", "区分保存到 OPFS 与手动下载 .blend"], - "steps": [ - { "text": "在浏览器打开 /?ui=web 。如果是远端地址,先等待底部 wasm 加载完成,状态栏出现 SceneIR。", "image": "steps/M01-interface.jpg" }, - { "text": "顶部蓝色标签是工作区:Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting。课程会逐个使用。", "image": "steps/M01-interface.jpg" }, - { "text": "点击顶栏“打开 .blend”(或按 F3 搜索“打开”),选择课程工程 studio.blend。等待状态栏对象数变为 6。", "image": "steps/M01-file-menu.jpg" }, - { "text": "再打开 re/猴头.glb。GLB 会追加到当前场景,对象数从 6 变为 7,活动对象是 Suzanne。", "image": "steps/M01-interface.jpg" }, - { "text": "点击“保存项目”。它写入浏览器 OPFS,并显示保存状态 SUCCEEDED;不会下载文件。需要本地副本时,单独点击“下载 .blend”。", "image": "steps/M01-file-menu.jpg" } + "objectives": [ + "找到并切换工作区", + "用“打开 .blend”载入练习工程", + "区分保存到 OPFS 与手动下载 .blend" ], - "tips": ["工作区切换只改变布局,不改变场景数据。", "状态栏右侧的引擎状态是判断操作是否真正提交的第一现场。"], - "warnings": ["不要在保存流程中寻找自动下载;下载是独立的手动动作。"] + "steps": [ + { + "text": "在浏览器打开 /?ui=web 。如果是远端地址,先等待底部 wasm 加载完成,状态栏出现 SceneIR。", + "image": "steps/M01-interface.jpg" + }, + { + "text": "顶部蓝色标签是工作区:Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting。课程会逐个使用。", + "image": "steps/M01-interface.jpg" + }, + { + "text": "点击顶栏“打开 .blend”(或按 F3 搜索“打开”),选择课程工程 studio.blend。等待状态栏对象数变为 6。", + "image": "steps/M01-file-menu.jpg" + }, + { + "text": "再打开 re/猴头.glb。GLB 会追加到当前场景,对象数从 6 变为 7,活动对象是 Suzanne。", + "image": "steps/M01-interface.jpg" + }, + { + "text": "点击“保存项目”。它写入浏览器 OPFS,并显示保存状态 SUCCEEDED;不会下载文件。需要本地副本时,单独点击“下载 .blend”。", + "image": "steps/M01-file-menu.jpg" + } + ], + "tips": [ + "工作区切换只改变布局,不改变场景数据。", + "状态栏右侧的引擎状态是判断操作是否真正提交的第一现场。" + ], + "warnings": [ + "不要在保存流程中寻找自动下载;下载是独立的手动动作。" + ], + "video": { + "id": "M01", + "src": "video/M01-lesson-720p.mp4", + "poster": "video/M01-lesson-poster.jpg", + "vtt": "video/M01-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M01 动手操作录像,中文配音与字幕。" + } }, { "id": "M02", @@ -53,15 +88,47 @@ "level": "入门", "minutes": 7, "summary": "在三维视口旋转、平移、缩放;从大纲视图选择对象;用操作搜索找到桌面版同名命令。", - "objectives": ["使用鼠标中键旋转、Shift+中键平移、滚轮缩放", "在大纲视图选择活动对象", "用 F3 搜索并执行操作"], - "steps": [ - { "text": "在 Layout 工作区,按住鼠标中键拖动旋转视角;Shift+中键平移;滚轮缩放。视口右上角的导航球可以恢复轴向视图。", "image": "steps/M02-layout.jpg" }, - { "text": "右侧大纲视图列出场景对象。单击一个对象即可设为活动对象;视口与属性面板会同步更新。", "image": "steps/M02-selection.jpg" }, - { "text": "按 F3 打开操作搜索,输入“移动”,可以看到与桌面 Blender 对齐的命令。选中命令后按提示在视口或属性中完成操作。", "image": "steps/M02-operator-search.jpg" }, - { "text": "练习:选中猴头,在属性面板把位置 Z 改为 0.5,再改回 0;观察视口和状态栏。", "image": "steps/M02-selection.jpg" } + "objectives": [ + "使用鼠标中键旋转、Shift+中键平移、滚轮缩放", + "在大纲视图选择活动对象", + "用 F3 搜索并执行操作" ], - "tips": ["属性面板的数值修改会进入原生 Main,并支持撤销。", "不确定命令在哪时,优先用 F3 搜索,而不是猜菜单位置。"], - "warnings": ["删除、布尔等破坏性操作先在练习工程上做,并记住撤销按钮。"] + "steps": [ + { + "text": "在 Layout 工作区,按住鼠标中键拖动旋转视角;Shift+中键平移;滚轮缩放。视口右上角的导航球可以恢复轴向视图。", + "image": "steps/M02-layout.jpg" + }, + { + "text": "右侧大纲视图列出场景对象。单击一个对象即可设为活动对象;视口与属性面板会同步更新。", + "image": "steps/M02-selection.jpg" + }, + { + "text": "按 F3 打开操作搜索,输入“移动”,可以看到与桌面 Blender 对齐的命令。选中命令后按提示在视口或属性中完成操作。", + "image": "steps/M02-operator-search.jpg" + }, + { + "text": "练习:选中猴头,在属性面板把位置 Z 改为 0.5,再改回 0;观察视口和状态栏。", + "image": "steps/M02-selection.jpg" + } + ], + "tips": [ + "属性面板的数值修改会进入原生 Main,并支持撤销。", + "不确定命令在哪时,优先用 F3 搜索,而不是猜菜单位置。" + ], + "warnings": [ + "删除、布尔等破坏性操作先在练习工程上做,并记住撤销按钮。" + ], + "video": { + "id": "M02", + "src": "video/M02-lesson-720p.mp4", + "poster": "video/M02-lesson-poster.jpg", + "vtt": "video/M02-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M02 动手操作录像,中文配音与字幕。" + } }, { "id": "M03", @@ -69,15 +136,47 @@ "level": "基础", "minutes": 9, "summary": "进入编辑模式,认识顶点/边/面选择,执行挤出、倒角、细分等常用网格操作。", - "objectives": ["在物体模式与编辑模式之间切换", "切换 1/2/3 选择模式", "执行一次挤出或倒角并撤销"], - "steps": [ - { "text": "切换到 Modeling 工作区。左侧快捷栏提供 Add Cube、Add Monkey、Duplicate、Delete 等对象操作。", "image": "steps/M03-workspace.jpg" }, - { "text": "点击左上角模式按钮(Object Mode),选择“编辑模式”。视口进入 Edit Mode,并显示网格元素。", "image": "steps/M03-edit-mode.jpg" }, - { "text": "在视口上方选择 1 Vertex / 2 Edge / 3 Face,或使用工具栏按钮切换选择模式。点击 Select All 选中全部元素。", "image": "steps/M03-mesh-selection.jpg" }, - { "text": "点击 EXTRUDE 或 BEVEL 执行一次操作;按撤销按钮恢复。注意状态栏是否显示命令已提交。", "image": "steps/M03-mesh-selection.jpg" } + "objectives": [ + "在物体模式与编辑模式之间切换", + "切换 1/2/3 选择模式", + "执行一次挤出或倒角并撤销" ], - "tips": ["编辑模式的选择集保存在原生 Main,可以撤销。", "面模式下才能执行 TRIANGULATE 和 RECALCULATE NORMALS。"], - "warnings": ["对高面数网格做全选挤出会明显变慢;课程使用 1225 顶点的猴头。"] + "steps": [ + { + "text": "切换到 Modeling 工作区。左侧快捷栏提供 Add Cube、Add Monkey、Duplicate、Delete 等对象操作。", + "image": "steps/M03-workspace.jpg" + }, + { + "text": "点击左上角模式按钮(Object Mode),选择“编辑模式”。视口进入 Edit Mode,并显示网格元素。", + "image": "steps/M03-edit-mode.jpg" + }, + { + "text": "在视口上方选择 1 Vertex / 2 Edge / 3 Face,或使用工具栏按钮切换选择模式。点击 Select All 选中全部元素。", + "image": "steps/M03-mesh-selection.jpg" + }, + { + "text": "点击 EXTRUDE 或 BEVEL 执行一次操作;按撤销按钮恢复。注意状态栏是否显示命令已提交。", + "image": "steps/M03-mesh-selection.jpg" + } + ], + "tips": [ + "编辑模式的选择集保存在原生 Main,可以撤销。", + "面模式下才能执行 TRIANGULATE 和 RECALCULATE NORMALS。" + ], + "warnings": [ + "对高面数网格做全选挤出会明显变慢;课程使用 1225 顶点的猴头。" + ], + "video": { + "id": "M03", + "src": "video/M03-lesson-720p.mp4", + "poster": "video/M03-lesson-poster.jpg", + "vtt": "video/M03-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M03 动手操作录像,中文配音与字幕。" + } }, { "id": "M04", @@ -85,14 +184,43 @@ "level": "基础", "minutes": 8, "summary": "在属性面板查看并编辑修改器栈;理解修改器是非破坏性的,渲染和导出时才求值。", - "objectives": ["找到修改器面板", "添加/启用/应用一个修改器", "理解修改器与网格编辑的区别"], - "steps": [ - { "text": "选中猴头,在 Layout 工作区查看右侧属性面板。向下滚动到 Modifiers 区域。", "image": "steps/M04-properties.jpg" }, - { "text": "修改器会按顺序求值。点击添加修改器,选择 Bevel、Boolean、Mirror 或 Array;调整参数后视口立即更新。", "image": "steps/M04-modifier-stack.jpg" }, - { "text": "练习:给猴头添加 Bevel,宽度设为 0.01;观察面数变化后撤销。", "image": "steps/M04-modifier-stack.jpg" } + "objectives": [ + "找到修改器面板", + "添加/启用/应用一个修改器", + "理解修改器与网格编辑的区别" ], - "tips": ["修改器栈顺序会影响结果,Boolean 通常放在最后。", "应用修改器会改变网格数据;课程建议先保留修改器版本。"], - "warnings": ["Boolean 需要封闭网格;非流形网格可能出现破面。"] + "steps": [ + { + "text": "选中猴头,在 Layout 工作区查看右侧属性面板。向下滚动到 Modifiers 区域。", + "image": "steps/M04-properties.jpg" + }, + { + "text": "修改器会按顺序求值。点击添加修改器,选择 Bevel、Boolean、Mirror 或 Array;调整参数后视口立即更新。", + "image": "steps/M04-modifier-stack.jpg" + }, + { + "text": "练习:给猴头添加 Bevel,宽度设为 0.01;观察面数变化后撤销。", + "image": "steps/M04-modifier-stack.jpg" + } + ], + "tips": [ + "修改器栈顺序会影响结果,Boolean 通常放在最后。", + "应用修改器会改变网格数据;课程建议先保留修改器版本。" + ], + "warnings": [ + "Boolean 需要封闭网格;非流形网格可能出现破面。" + ], + "video": { + "id": "M04", + "src": "video/M04-lesson-720p.mp4", + "poster": "video/M04-lesson-poster.jpg", + "vtt": "video/M04-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M04 动手操作录像,中文配音与字幕。" + } }, { "id": "M05", @@ -100,15 +228,47 @@ "level": "基础", "minutes": 10, "summary": "在 Shading 工作区认识材质槽、节点编辑器和 RE 5000PBR 资产库;双击资产分配材质。", - "objectives": ["切换 Shading 工作区", "从资产库分配一个 PBR 材质", "在材质属性中确认 Base Color 贴图"], - "steps": [ - { "text": "切换到 Shading 工作区。上方是 Shader Editor,右侧是材质属性,左下是 PBR 资产库。", "image": "steps/M05-shader-workspace.jpg" }, - { "text": "在“资产来源”下拉框选择 library:re-pbr-v1,只显示六张 RE 材质卡片。单击只选中,双击才分配。", "image": "steps/M05-asset-library.jpg" }, - { "text": "双击 black_tiles_wall。等待材质槽显示 material:black_tiles_wall,并确认材质球状态为 COMPILED。", "image": "steps/M05-asset-library.jpg" }, - { "text": "在材质属性中检查 Base Color 图片;M07 会在这张图上手绘。", "image": "steps/M05-shader-workspace.jpg" } + "objectives": [ + "切换 Shading 工作区", + "从资产库分配一个 PBR 材质", + "在材质属性中确认 Base Color 贴图" ], - "tips": ["资产库缩略图加载需要时间,先等卡片出现再双击。", "同一材质可以分配给多个对象;修改会影响所有使用者。"], - "warnings": ["库链接贴图不能直接被纹理绘制修改;M07 会导入本地派生的 PNG。"] + "steps": [ + { + "text": "切换到 Shading 工作区。上方是 Shader Editor,右侧是材质属性,左下是 PBR 资产库。", + "image": "steps/M05-shader-workspace.jpg" + }, + { + "text": "在“资产来源”下拉框选择 library:re-pbr-v1,只显示六张 RE 材质卡片。单击只选中,双击才分配。", + "image": "steps/M05-asset-library.jpg" + }, + { + "text": "双击 black_tiles_wall。等待材质槽显示 material:black_tiles_wall,并确认材质球状态为 COMPILED。", + "image": "steps/M05-asset-library.jpg" + }, + { + "text": "在材质属性中检查 Base Color 图片;M07 会在这张图上手绘。", + "image": "steps/M05-shader-workspace.jpg" + } + ], + "tips": [ + "资产库缩略图加载需要时间,先等卡片出现再双击。", + "同一材质可以分配给多个对象;修改会影响所有使用者。" + ], + "warnings": [ + "库链接贴图不能直接被纹理绘制修改;M07 会导入本地派生的 PNG。" + ], + "video": { + "id": "M05", + "src": "video/M05-lesson-720p.mp4", + "poster": "video/M05-lesson-poster.jpg", + "vtt": "video/M05-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M05 动手操作录像,中文配音与字幕。" + } }, { "id": "M06", @@ -116,15 +276,47 @@ "level": "进阶", "minutes": 8, "summary": "在 UV Editing 工作区进入编辑模式,全选 UV、执行展开并提交;保存重开后 UV 仍然存在。", - "objectives": ["在 ?ui=web 下找到可写 UV 区域", "全选并展开 UV", "保存到 OPFS 后重开确认 UV 未丢失"], - "steps": [ - { "text": "切换到 UV Editing 工作区。左侧是原生 UV 编辑器,右侧是三维视口。桌面 Blender 要求网格处于编辑模式,本课程也显式进入编辑模式。", "image": "steps/M06-uv-workspace.jpg" }, - { "text": "点击“全选 UV”,等待 UV 编辑器显示“UV 选择已提交”。橙色点是选中的 UV。", "image": "steps/M06-uv-workspace.jpg" }, - { "text": "点击“展开所选 UV”,等待显示“展开 UV已提交”。视口和 UV 布局会更新。", "image": "steps/M06-unwrap.jpg" }, - { "text": "点击顶栏“保存项目”写入 OPFS,然后从最近项目重新打开;UV 控件和选择仍然存在。", "image": "steps/M06-unwrap.jpg" } + "objectives": [ + "在 ?ui=web 下找到可写 UV 区域", + "全选并展开 UV", + "保存到 OPFS 后重开确认 UV 未丢失" ], - "tips": ["展开前先标记缝合线,能得到更整齐的 UV 岛。", "课程只使用角度展开;其他方法在 UV 控件中也有入口。"], - "warnings": ["UV 操作需要可写原生 UV 区域;如果提示未提交,先确认已进入编辑模式。"] + "steps": [ + { + "text": "切换到 UV Editing 工作区。左侧是原生 UV 编辑器,右侧是三维视口。桌面 Blender 要求网格处于编辑模式,本课程也显式进入编辑模式。", + "image": "steps/M06-uv-workspace.jpg" + }, + { + "text": "点击“全选 UV”,等待 UV 编辑器显示“UV 选择已提交”。橙色点是选中的 UV。", + "image": "steps/M06-uv-workspace.jpg" + }, + { + "text": "点击“展开所选 UV”,等待显示“展开 UV已提交”。视口和 UV 布局会更新。", + "image": "steps/M06-unwrap.jpg" + }, + { + "text": "点击顶栏“保存项目”写入 OPFS,然后从最近项目重新打开;UV 控件和选择仍然存在。", + "image": "steps/M06-unwrap.jpg" + } + ], + "tips": [ + "展开前先标记缝合线,能得到更整齐的 UV 岛。", + "课程只使用角度展开;其他方法在 UV 控件中也有入口。" + ], + "warnings": [ + "UV 操作需要可写原生 UV 区域;如果提示未提交,先确认已进入编辑模式。" + ], + "video": { + "id": "M06", + "src": "video/M06-lesson-720p.mp4", + "poster": "video/M06-lesson-poster.jpg", + "vtt": "video/M06-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M06 动手操作录像,中文配音与字幕。" + } }, { "id": "M07", @@ -132,15 +324,47 @@ "level": "进阶", "minutes": 10, "summary": "导入本地 Base Color 贴图,在 Texture Paint 工作区用画笔绘制一条笔画,一次提交并撤销。", - "objectives": ["给猴头分配带贴图的材质", "选择图像并调整画笔颜色/大小/强度", "绘制、撤销、保存重开"], - "steps": [ - { "text": "按 M05 分配 black_tiles_wall,并在材质属性中导入课程图片 re-texture-base.png,设为 Base Color 的“材质图片”。", "image": "steps/M07-texture-paint.jpg" }, - { "text": "切换到 Texture Paint 工作区。顶部选择纹理图像,左侧是图像画布,下面是绘制/擦除、颜色、大小、强度。", "image": "steps/M07-texture-paint.jpg" }, - { "text": "在画布上按住左键拖动,画出一条白色笔画。松开后显示“笔画已提交”,并增加一个 Main revision。", "image": "steps/M07-painted-stroke.jpg" }, - { "text": "点击“撤销笔画”恢复原图;再点“重做笔画”。保存到 OPFS 后重开,笔画仍在。", "image": "steps/M07-painted-stroke.jpg" } + "objectives": [ + "给猴头分配带贴图的材质", + "选择图像并调整画笔颜色/大小/强度", + "绘制、撤销、保存重开" ], - "tips": ["一次指针笔画只提交一次,避免每个采样点都写 Main。", "图像越大,单次提交的 PNG 编码越慢;课程使用 1024×1024。"], - "warnings": ["库链接图像会被原生拒绝(cannot modify linked library images);请先导入本地图片。"] + "steps": [ + { + "text": "按 M05 分配 black_tiles_wall,并在材质属性中导入课程图片 re-texture-base.png,设为 Base Color 的“材质图片”。", + "image": "steps/M07-texture-paint.jpg" + }, + { + "text": "切换到 Texture Paint 工作区。顶部选择纹理图像,左侧是图像画布,下面是绘制/擦除、颜色、大小、强度。", + "image": "steps/M07-texture-paint.jpg" + }, + { + "text": "在画布上按住左键拖动,画出一条白色笔画。松开后显示“笔画已提交”,并增加一个 Main revision。", + "image": "steps/M07-painted-stroke.jpg" + }, + { + "text": "点击“撤销笔画”恢复原图;再点“重做笔画”。保存到 OPFS 后重开,笔画仍在。", + "image": "steps/M07-painted-stroke.jpg" + } + ], + "tips": [ + "一次指针笔画只提交一次,避免每个采样点都写 Main。", + "图像越大,单次提交的 PNG 编码越慢;课程使用 1024×1024。" + ], + "warnings": [ + "库链接图像会被原生拒绝(cannot modify linked library images);请先导入本地图片。" + ], + "video": { + "id": "M07", + "src": "video/M07-lesson-720p.mp4", + "poster": "video/M07-lesson-poster.jpg", + "vtt": "video/M07-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M07 动手操作录像,中文配音与字幕。" + } }, { "id": "M08", @@ -148,15 +372,47 @@ "level": "进阶", "minutes": 12, "summary": "在 Rendering 工作区设置相机、分辨率、曝光与位移,执行显卡 PBR 渲染。", - "objectives": ["设置 1024×1024 场景相机渲染", "理解最终材质视图与教学曝光", "执行渲染并拿到结果"], - "steps": [ - { "text": "切换到 Rendering 工作区。左侧是渲染结果视口,下方是 PBR 渲染练习面板,右侧是场景属性。", "image": "steps/M08-render-workspace.jpg" }, - { "text": "视角预设选择“场景相机对齐”,分辨率 1024×1024,视图“最终材质”,教学曝光 0 EV,位移强度 1×。", "image": "steps/M08-pbr-panel.jpg" }, - { "text": "点击“渲染 PBR”。面板会等待纹理与环境贴图就绪,然后用 GPU 输出成片。", "image": "steps/M08-pbr-panel.jpg" }, - { "text": "结果出现后,可在诊断视图切换 Base Color、Normal、Roughness 检查材质。", "image": "steps/M09-render-result.jpg" } + "objectives": [ + "设置 1024×1024 场景相机渲染", + "理解最终材质视图与教学曝光", + "执行渲染并拿到结果" ], - "tips": ["深色材质只看整体亮度容易误判,切到 Base Color 更可靠。", "完整操作录像见本页顶部“渲染专课”。"], - "warnings": ["渲染依赖硬件 WebGL;SwiftShader 回退会失败并明确报错。"] + "steps": [ + { + "text": "切换到 Rendering 工作区。左侧是渲染结果视口,下方是 PBR 渲染练习面板,右侧是场景属性。", + "image": "steps/M08-render-workspace.jpg" + }, + { + "text": "视角预设选择“场景相机对齐”,分辨率 1024×1024,视图“最终材质”,教学曝光 0 EV,位移强度 1×。", + "image": "steps/M08-pbr-panel.jpg" + }, + { + "text": "点击“渲染 PBR”。面板会等待纹理与环境贴图就绪,然后用 GPU 输出成片。", + "image": "steps/M08-pbr-panel.jpg" + }, + { + "text": "结果出现后,可在诊断视图切换 Base Color、Normal、Roughness 检查材质。", + "image": "steps/M09-render-result.jpg" + } + ], + "tips": [ + "深色材质只看整体亮度容易误判,切到 Base Color 更可靠。", + "完整操作录像见本页顶部“渲染专课”。" + ], + "warnings": [ + "渲染依赖硬件 WebGL;SwiftShader 回退会失败并明确报错。" + ], + "video": { + "id": "M08", + "src": "video/M08-lesson-720p.mp4", + "poster": "video/M08-lesson-poster.jpg", + "vtt": "video/M08-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M08 动手操作录像,中文配音与字幕。" + } }, { "id": "M09", @@ -164,15 +420,47 @@ "level": "进阶", "minutes": 8, "summary": "检查渲染结果尺寸与耗时,下载 PNG,导出 GLB,并核对保存状态。", - "objectives": ["确认渲染结果 1024×1024", "下载 PNG", "导出 GLB 并保存 OPFS"], - "steps": [ - { "text": "渲染完成后,结果视口底部显示分辨率、字节数和 OPFS 引用。确认是 1024×1024。", "image": "steps/M09-render-result.jpg" }, - { "text": "点击“下载 PNG”保存本地成片;这是显式手动下载。", "image": "steps/M09-render-result.jpg" }, - { "text": "点击顶栏“导出 GLB”导出当前场景;导出前确认对象数和活动对象。", "image": "steps/M09-render-result.jpg" }, - { "text": "点击“保存项目”写入 OPFS;重新打开最近项目,确认材质和相机仍在。", "image": "steps/M09-render-result.jpg" } + "objectives": [ + "确认渲染结果 1024×1024", + "下载 PNG", + "导出 GLB 并保存 OPFS" ], - "tips": ["渲染耗时受分辨率、贴图大小和显卡影响。", "诊断报告按钮会记录失败阶段,便于定位。"], - "warnings": ["导出 GLB 不会包含未打包的外部资源;课程资产已打包。"] + "steps": [ + { + "text": "渲染完成后,结果视口底部显示分辨率、字节数和 OPFS 引用。确认是 1024×1024。", + "image": "steps/M09-render-result.jpg" + }, + { + "text": "点击“下载 PNG”保存本地成片;这是显式手动下载。", + "image": "steps/M09-render-result.jpg" + }, + { + "text": "点击顶栏“导出 GLB”导出当前场景;导出前确认对象数和活动对象。", + "image": "steps/M09-render-result.jpg" + }, + { + "text": "点击“保存项目”写入 OPFS;重新打开最近项目,确认材质和相机仍在。", + "image": "steps/M09-render-result.jpg" + } + ], + "tips": [ + "渲染耗时受分辨率、贴图大小和显卡影响。", + "诊断报告按钮会记录失败阶段,便于定位。" + ], + "warnings": [ + "导出 GLB 不会包含未打包的外部资源;课程资产已打包。" + ], + "video": { + "id": "M09", + "src": "video/M09-lesson-720p.mp4", + "poster": "video/M09-lesson-poster.jpg", + "vtt": "video/M09-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M09 动手操作录像,中文配音与字幕。" + } }, { "id": "M10", @@ -180,15 +468,47 @@ "level": "基础", "minutes": 8, "summary": "在 Animation 工作区跳帧、插入位置关键帧、播放并保存动作。", - "objectives": ["切换 Animation 工作区", "在指定帧插入位置关键帧", "播放并确认关键帧出现在 Dope Sheet"], - "steps": [ - { "text": "切换到 Animation 工作区。底部是时间线、Dope Sheet 和 NLA 编辑区。", "image": "steps/M10-animation-workspace.jpg" }, - { "text": "把当前帧改为 12,选中猴头,点击“插入位置关键帧”(按钮 Loc)。", "image": "steps/M10-keyframe.jpg" }, - { "text": "再跳到第 48 帧,移动对象后插入第二个位置关键帧;点击播放观察插值。", "image": "steps/M10-keyframe.jpg" }, - { "text": "保存到 OPFS 后重开,Dope Sheet 中仍能看到关键帧。", "image": "steps/M10-keyframe.jpg" } + "objectives": [ + "切换 Animation 工作区", + "在指定帧插入位置关键帧", + "播放并确认关键帧出现在 Dope Sheet" ], - "tips": ["Loc/Rot/Scale 分别插入位置、旋转和缩放关键帧。", "删除关键帧只删除当前帧的对应通道。"], - "warnings": ["动画工作区右上角的 Camera Perspective 是布局示意;完整相机视图在 Layout 工作区。"] + "steps": [ + { + "text": "切换到 Animation 工作区。底部是时间线、Dope Sheet 和 NLA 编辑区。", + "image": "steps/M10-animation-workspace.jpg" + }, + { + "text": "把当前帧改为 12,选中猴头,点击“插入位置关键帧”(按钮 Loc)。", + "image": "steps/M10-keyframe.jpg" + }, + { + "text": "再跳到第 48 帧,移动对象后插入第二个位置关键帧;点击播放观察插值。", + "image": "steps/M10-keyframe.jpg" + }, + { + "text": "保存到 OPFS 后重开,Dope Sheet 中仍能看到关键帧。", + "image": "steps/M10-keyframe.jpg" + } + ], + "tips": [ + "Loc/Rot/Scale 分别插入位置、旋转和缩放关键帧。", + "删除关键帧只删除当前帧的对应通道。" + ], + "warnings": [ + "动画工作区右上角的 Camera Perspective 是布局示意;完整相机视图在 Layout 工作区。" + ], + "video": { + "id": "M10", + "src": "video/M10-lesson-720p.mp4", + "poster": "video/M10-lesson-poster.jpg", + "vtt": "video/M10-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M10 动手操作录像,中文配音与字幕。" + } }, { "id": "M11", @@ -196,16 +516,51 @@ "level": "进阶", "minutes": 12, "summary": "在 Compositing 工作区创建 RGB 与 Exposure 节点,连线到 Composite,执行并查看合成结果。", - "objectives": ["添加节点并拖拽连线", "编辑节点属性", "执行合成并提交到原生 Main"], - "steps": [ - { "text": "切换到 Compositing 工作区。画布默认有一个 Composite 输出节点,右侧是属性检查器和结果预览。", "image": "steps/M11-compositor.jpg" }, - { "text": "在“添加节点类型”中选择 RGB 颜色,点击“添加节点”;再添加曝光 Exposure。", "image": "steps/M11-nodes-added.jpg" }, - { "text": "从 RGB 的输出端口拖到 Exposure 的输入端口;再从 Exposure 拖到 Composite 的 Image 输入。", "image": "steps/M11-nodes-added.jpg" }, - { "text": "选中 Exposure,把曝光改为 1.5;点击“执行合成”,右侧预览会显示提亮后的颜色。", "image": "steps/M11-executed.jpg" }, - { "text": "点击“提交图”写入原生 CompositorNodeTree;保存 OPFS 后重开,节点、连线、位置和属性都保留。", "image": "steps/M11-executed.jpg" } + "objectives": [ + "添加节点并拖拽连线", + "编辑节点属性", + "执行合成并提交到原生 Main" ], - "tips": ["Blur、Mix、Alpha Over、Transform 也在同一画布中;Mix 需要 A/B 两路输入。", "Viewer 节点会显示在当前阶段的结果。"], - "warnings": ["IMAGE/RENDER_LAYER 节点可以本地求值,但暂不写入 Main;保存前请移除或替换为 RGB。"] + "steps": [ + { + "text": "切换到 Compositing 工作区。画布默认有一个 Composite 输出节点,右侧是属性检查器和结果预览。", + "image": "steps/M11-compositor.jpg" + }, + { + "text": "在“添加节点类型”中选择 RGB 颜色,点击“添加节点”;再添加曝光 Exposure。", + "image": "steps/M11-nodes-added.jpg" + }, + { + "text": "从 RGB 的输出端口拖到 Exposure 的输入端口;再从 Exposure 拖到 Composite 的 Image 输入。", + "image": "steps/M11-nodes-added.jpg" + }, + { + "text": "选中 Exposure,把曝光改为 1.5;点击“执行合成”,右侧预览会显示提亮后的颜色。", + "image": "steps/M11-executed.jpg" + }, + { + "text": "点击“提交图”写入原生 CompositorNodeTree;保存 OPFS 后重开,节点、连线、位置和属性都保留。", + "image": "steps/M11-executed.jpg" + } + ], + "tips": [ + "Blur、Mix、Alpha Over、Transform 也在同一画布中;Mix 需要 A/B 两路输入。", + "Viewer 节点会显示在当前阶段的结果。" + ], + "warnings": [ + "IMAGE/RENDER_LAYER 节点可以本地求值,但暂不写入 Main;保存前请移除或替换为 RGB。" + ], + "video": { + "id": "M11", + "src": "video/M11-lesson-720p.mp4", + "poster": "video/M11-lesson-poster.jpg", + "vtt": "video/M11-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M11 动手操作录像,中文配音与字幕。" + } }, { "id": "M12", @@ -213,15 +568,47 @@ "level": "进阶", "minutes": 12, "summary": "在 Geometry Nodes 工作区添加 Transform 节点,与 Group Input 连线并提交,观察几何更新。", - "objectives": ["读取当前网格的 Geometry Nodes 图", "添加节点并连线", "提交图并保存重开"], - "steps": [ - { "text": "选中猴头,切换到 Geometry Nodes 工作区。上方是节点画布,右侧是节点检查器,下方是 Spreadsheet。", "image": "steps/M12-geometry-nodes.jpg" }, - { "text": "在“添加节点类型”中选择 GeometryNodeTransform,点击“添加节点”。画布出现新节点。", "image": "steps/M12-node-added.jpg" }, - { "text": "从 Group Input 的 Geometry 端口拖到 Transform 的 Geometry 输入;再把 Transform 输出连到 Group Output。", "image": "steps/M12-node-added.jpg" }, - { "text": "在检查器修改平移/缩放默认值,点击“提交图”。网格随修改器更新,版本号增加。", "image": "steps/M12-node-added.jpg" } + "objectives": [ + "读取当前网格的 Geometry Nodes 图", + "添加节点并连线", + "提交图并保存重开" ], - "tips": ["课程只使用 allowlist 中的 16 类节点,避免构造无法执行的图。", "提交失败时,检查器会显示具体节点或 socket 错误。"], - "warnings": ["节点位置目前只在当前会话保存;保存重开保留节点和连线,但可能重置布局。"] + "steps": [ + { + "text": "选中猴头,切换到 Geometry Nodes 工作区。上方是节点画布,右侧是节点检查器,下方是 Spreadsheet。", + "image": "steps/M12-geometry-nodes.jpg" + }, + { + "text": "在“添加节点类型”中选择 GeometryNodeTransform,点击“添加节点”。画布出现新节点。", + "image": "steps/M12-node-added.jpg" + }, + { + "text": "从 Group Input 的 Geometry 端口拖到 Transform 的 Geometry 输入;再把 Transform 输出连到 Group Output。", + "image": "steps/M12-node-added.jpg" + }, + { + "text": "在检查器修改平移/缩放默认值,点击“提交图”。网格随修改器更新,版本号增加。", + "image": "steps/M12-node-added.jpg" + } + ], + "tips": [ + "课程只使用 allowlist 中的 16 类节点,避免构造无法执行的图。", + "提交失败时,检查器会显示具体节点或 socket 错误。" + ], + "warnings": [ + "节点位置目前只在当前会话保存;保存重开保留节点和连线,但可能重置布局。" + ], + "video": { + "id": "M12", + "src": "video/M12-lesson-720p.mp4", + "poster": "video/M12-lesson-poster.jpg", + "vtt": "video/M12-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M12 动手操作录像,中文配音与字幕。" + } }, { "id": "M13", @@ -229,15 +616,47 @@ "level": "进阶", "minutes": 8, "summary": "用 Spreadsheet 只读表查看对象、顶点、边、面、UV 与几何属性;过滤、列显示/重排并导出 CSV。", - "objectives": ["切换数据源", "过滤顶点并隐藏列", "导出 CSV 且不修改场景"], - "steps": [ - { "text": "在 Geometry Nodes 工作区下方打开 Spreadsheet。默认显示对象表:名称、类型、数据、可见性、位置。", "image": "steps/M13-objects.jpg" }, - { "text": "把数据源切到“顶点 Vertices”,可以看到 Position X/Y/Z 和 Normal 列。", "image": "steps/M13-vertices.jpg" }, - { "text": "在过滤框输入 0.5,只剩包含该值的行;在“列”面板隐藏 index 或调整列顺序。", "image": "steps/M13-filtered.jpg" }, - { "text": "点击“导出 CSV”手动下载当前可见列。Spreadsheet 是只读的,不会产生 Main revision。", "image": "steps/M13-filtered.jpg" } + "objectives": [ + "切换数据源", + "过滤顶点并隐藏列", + "导出 CSV 且不修改场景" ], - "tips": ["顶点/边/面/UV 读取当前网格缓冲;属性表列出 position、normal、UVMap 等。", "单表最多 50,000 行,超出会在脚注提示。"], - "warnings": ["Spreadsheet 不读取 GN 修改器求值后的属性域;它显示当前网格缓冲。"] + "steps": [ + { + "text": "在 Geometry Nodes 工作区下方打开 Spreadsheet。默认显示对象表:名称、类型、数据、可见性、位置。", + "image": "steps/M13-objects.jpg" + }, + { + "text": "把数据源切到“顶点 Vertices”,可以看到 Position X/Y/Z 和 Normal 列。", + "image": "steps/M13-vertices.jpg" + }, + { + "text": "在过滤框输入 0.5,只剩包含该值的行;在“列”面板隐藏 index 或调整列顺序。", + "image": "steps/M13-filtered.jpg" + }, + { + "text": "点击“导出 CSV”手动下载当前可见列。Spreadsheet 是只读的,不会产生 Main revision。", + "image": "steps/M13-filtered.jpg" + } + ], + "tips": [ + "顶点/边/面/UV 读取当前网格缓冲;属性表列出 position、normal、UVMap 等。", + "单表最多 50,000 行,超出会在脚注提示。" + ], + "warnings": [ + "Spreadsheet 不读取 GN 修改器求值后的属性域;它显示当前网格缓冲。" + ], + "video": { + "id": "M13", + "src": "video/M13-lesson-720p.mp4", + "poster": "video/M13-lesson-poster.jpg", + "vtt": "video/M13-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M13 动手操作录像,中文配音与字幕。" + } }, { "id": "M14", @@ -245,15 +664,47 @@ "level": "基础", "minutes": 8, "summary": "使用 OPFS 保存与最近项目恢复;理解恢复检查点、错误提示和手动下载的边界。", - "objectives": ["保存到 OPFS 并确认 SUCCEEDED", "从最近项目重开", "需要本地副本时手动下载"], - "steps": [ - { "text": "点击顶栏“保存项目”。状态栏显示 data-user-action-save-status=SUCCEEDED,表示已写入 OPFS。", "image": "steps/M14-opfs-save.jpg" }, - { "text": "刷新页面。最近项目下拉框出现刚才的工程;选择它并确认“不保存,继续”。", "image": "steps/M14-reopen.jpg" }, - { "text": "重开后检查对象数、活动对象、材质槽、UV 选择和渲染结果是否仍在。", "image": "steps/M14-reopen.jpg" }, - { "text": "如需本地文件,单独点击“下载 .blend”;不要把自动下载当作保存流程的一部分。", "image": "steps/M14-opfs-save.jpg" } + "objectives": [ + "保存到 OPFS 并确认 SUCCEEDED", + "从最近项目重开", + "需要本地副本时手动下载" ], - "tips": ["恢复失败时,页面会显示错误阶段;先导出诊断报告再重试。", "OPFS 数据与浏览器配置绑定,清理站点数据会删除工程。"], - "warnings": ["关闭浏览器前确认保存状态;未保存的编辑只在当前会话和恢复检查点中。"] + "steps": [ + { + "text": "点击顶栏“保存项目”。状态栏显示 data-user-action-save-status=SUCCEEDED,表示已写入 OPFS。", + "image": "steps/M14-opfs-save.jpg" + }, + { + "text": "刷新页面。最近项目下拉框出现刚才的工程;选择它并确认“不保存,继续”。", + "image": "steps/M14-reopen.jpg" + }, + { + "text": "重开后检查对象数、活动对象、材质槽、UV 选择和渲染结果是否仍在。", + "image": "steps/M14-reopen.jpg" + }, + { + "text": "如需本地文件,单独点击“下载 .blend”;不要把自动下载当作保存流程的一部分。", + "image": "steps/M14-opfs-save.jpg" + } + ], + "tips": [ + "恢复失败时,页面会显示错误阶段;先导出诊断报告再重试。", + "OPFS 数据与浏览器配置绑定,清理站点数据会删除工程。" + ], + "warnings": [ + "关闭浏览器前确认保存状态;未保存的编辑只在当前会话和恢复检查点中。" + ], + "video": { + "id": "M14", + "src": "video/M14-lesson-720p.mp4", + "poster": "video/M14-lesson-poster.jpg", + "vtt": "video/M14-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M14 动手操作录像,中文配音与字幕。" + } }, { "id": "M15", @@ -261,15 +712,47 @@ "level": "进阶", "minutes": 7, "summary": "在 Scripting 工作区查看文本编辑器、Python 控制台、Info 与操作历史;了解已支持与延期的能力。", - "objectives": ["打开 Scripting 工作区", "找到 Python 控制台和 Info", "识别课程未覆盖的限制"], - "steps": [ - { "text": "切换到 Scripting 工作区。上方是文本编辑器,下方左侧是 Python 控制台,右侧是 Info 操作日志。", "image": "steps/M15-scripting.jpg" }, - { "text": "Python 控制台在本机原生会话中执行;Web 会话是否可用取决于当前运行模式。", "image": "steps/M15-console.jpg" }, - { "text": "Info 区域显示最近命令;失败命令会给出错误码和阶段,便于定位。", "image": "steps/M15-console.jpg" }, - { "text": "已知限制:多区域 split/join、完整 Driver 编辑器、完整节点/画笔目录、生产级 UDIM/4K 纹理绘制尚未完成。", "image": "steps/M15-scripting.jpg" } + "objectives": [ + "打开 Scripting 工作区", + "找到 Python 控制台和 Info", + "识别课程未覆盖的限制" ], - "tips": ["支持矩阵与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。", "课程不宣称完整 Blender parity。"], - "warnings": ["脚本可修改 Main;执行前先保存到 OPFS 并保留撤销点。"] + "steps": [ + { + "text": "切换到 Scripting 工作区。上方是文本编辑器,下方左侧是 Python 控制台,右侧是 Info 操作日志。", + "image": "steps/M15-scripting.jpg" + }, + { + "text": "Python 控制台在本机原生会话中执行;Web 会话是否可用取决于当前运行模式。", + "image": "steps/M15-console.jpg" + }, + { + "text": "Info 区域显示最近命令;失败命令会给出错误码和阶段,便于定位。", + "image": "steps/M15-console.jpg" + }, + { + "text": "已知限制:多区域 split/join、完整 Driver 编辑器、完整节点/画笔目录、生产级 UDIM/4K 纹理绘制尚未完成。", + "image": "steps/M15-scripting.jpg" + } + ], + "tips": [ + "支持矩阵与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。", + "课程不宣称完整 Blender parity。" + ], + "warnings": [ + "脚本可修改 Main;执行前先保存到 OPFS 并保留撤销点。" + ], + "video": { + "id": "M15", + "src": "video/M15-lesson-720p.mp4", + "poster": "video/M15-lesson-poster.jpg", + "vtt": "video/M15-lesson.zh-Hans.vtt", + "durationTargetSeconds": [ + 180, + 480 + ], + "note": "M15 动手操作录像,中文配音与字幕。" + } } ] } diff --git a/tools/web/course/generate-full-course.mjs b/tools/web/course/generate-full-course.mjs index ed75cd6e2..0965e2f24 100644 --- a/tools/web/course/generate-full-course.mjs +++ b/tools/web/course/generate-full-course.mjs @@ -54,6 +54,13 @@ const modules = course.modules.map((module) => { return `
  • ${escapeHtml(step.text)}

    ${image}
  • `; }).join("\n "); const objectives = module.objectives.map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + const moduleVideo = module.video ? (() => { + const src = module.video.src; const poster = module.video.poster; const vtt = module.video.vtt; + exists(src); + const posterAttr = poster && exists(poster) ? ` poster="${escapeHtml(poster)}"` : ""; + const track = vtt && exists(vtt) ? `` : ""; + return `

    本模块操作录像

    ${escapeHtml(module.video.note ?? "")}

    `; + })() : ""; const tips = (module.tips ?? []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); const warnings = (module.warnings ?? []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); return `
    @@ -63,6 +70,7 @@ const modules = course.modules.map((module) => {

    ${escapeHtml(module.summary)}

    本模块目标

      ${objectives}
    + ${moduleVideo}
      ${steps}
    @@ -130,7 +138,10 @@ a{color:var(--accent)} .course-main{min-width:0;padding:26px clamp(18px,4vw,56px) 60px} .course-rules,.course-video,.course-module{max-width:1180px;margin:0 auto 34px;padding:22px clamp(16px,2.5vw,30px);background:var(--panel);border:1px solid var(--line);border-radius:8px} .course-rules h2,.course-video h2,.course-module h2{margin-top:0;line-height:1.25} -.course-video video{width:100%;height:auto;background:#000;border-radius:6px} +.course-video video,.course-module-video video{width:100%;height:auto;background:#000;border-radius:6px} +.course-module-video{margin:16px 0 20px;padding:14px 16px;background:#262626;border:1px solid #3d4a3f;border-radius:6px} +.course-module-video h3{margin:0 0 8px;font-size:15px;color:#cfe0ef} +.course-module-video p{margin:8px 0 0;color:#aaa;font-size:13px} .course-video p{margin:10px 0 0;color:var(--muted)} .course-module-id{margin:0 0 4px;color:var(--accent-2);font-size:12px;letter-spacing:.08em;text-transform:uppercase} .course-summary{margin:0;color:#cfcfcf} diff --git a/tools/web/course/record-module-video.mjs b/tools/web/course/record-module-video.mjs new file mode 100644 index 000000000..a51331948 --- /dev/null +++ b/tools/web/course/record-module-video.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node +/** + * Record and mix one narrated course module video (M01..M15). + * + * Usage: + * DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ + * node tools/web/course/record-module-video.mjs --module M01 --port 5184 --gpu hardware + * node tools/web/course/record-module-video.mjs --all --resume + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "../../.."); +const WEB = path.join(REPO, "web"); +const requireFromWeb = createRequire(path.join(WEB, "package.json")); +const { chromium } = requireFromWeb("playwright"); +const COURSE = JSON.parse(fs.readFileSync(path.join(HERE, "full-course.json"), "utf8")); +const STUDIO = path.join(WEB, "app/public/tutorial/pbr/studio.blend"); +const GLB = path.join(REPO, "re/猴头.glb"); +const TEXTURE = path.join(WEB, "app/public/tutorial/texture-paint/re-texture-base.png"); +const GOLDEN = path.join(REPO, "tests/golden/COURSE-VIDEOS"); +const PUBLIC_VIDEO = path.join(WEB, "app/public/tutorial/full/video"); +const PYTHON = "/usr/bin/python3"; +const NARRATION = path.join(REPO, "tools/web/generate-re-pbr-narration.py"); + +const args = process.argv.slice(2); +const flag = (name, fallback) => { const index = args.indexOf(`--${name}`); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; }; +const port = Number.parseInt(flag("port", "5184"), 10) || 5184; +const baseUrl = `http://127.0.0.1:${port}`; +const gpuMode = flag("gpu", process.env.GPU_MODE ?? "hardware"); +const resume = args.includes("--resume"); +const all = args.includes("--all"); +const requested = all ? COURSE.modules.map((module) => module.id) : [flag("module", "M01")]; +const hardwareArgs = ["--no-sandbox", "--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist", "--enable-gpu-rasterization", "--enable-zero-copy", "--enable-unsafe-webgpu", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--hide-scrollbars"]; + +const slug = (title) => title.toLowerCase().replace(/[^a-z0-9一-龥]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "module"; + +async function waitForServer() { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + try { const response = await fetch(baseUrl); if (response.status < 500) return; } catch { /* retry */ } + await new Promise((resolve) => setTimeout(resolve, 400)); + } + throw new Error(`server unavailable: ${baseUrl}`); +} + +async function installOverlay(page) { + await page.evaluate(() => { + if (!document.querySelector('link[data-lesson-video-overlay]')) { + const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "/tutorial/pbr/lesson/video-overlay.css"; link.dataset.lessonVideoOverlay = ""; document.head.append(link); + } + if (document.getElementById("__lesson_video_overlay")) return; + const root = document.createElement("div"); root.id = "__lesson_video_overlay"; + const cursor = document.createElement("div"); cursor.id = "__lesson_video_cursor"; + const caption = document.createElement("div"); caption.id = "__lesson_video_caption"; caption.textContent = "Web Blender · 全站入门课程"; + root.append(cursor, caption); document.body.append(root); + window.__lessonSetCaption = (text) => { caption.textContent = text; }; + window.addEventListener("mousemove", (event) => { cursor.getAnimations().forEach((animation) => animation.cancel()); cursor.animate([{ transform: `translate(${event.clientX}px, ${event.clientY}px)` }], { duration: 90, fill: "forwards", easing: "linear" }); }, { passive: true }); + window.addEventListener("mousedown", (event) => { const ripple = document.createElement("div"); ripple.className = "__lesson_video_ripple"; root.append(ripple); ripple.animate([{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(1)`, opacity: .95 }, { transform: `translate(${event.clientX}px, ${event.clientY}px) scale(4.2)`, opacity: 0 }], { duration: 520, easing: "ease-out" }).finished.then(() => ripple.remove()); }, { passive: true }); + }); +} + +const domClick = (locator) => locator.evaluate((element) => element.click()); + +async function drag(page, from, to) { + const a = await from.boundingBox(); const b = await to.boundingBox(); + if (!a || !b) throw new Error("drag handle has no box"); + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down(); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }); await page.mouse.up(); +} + +const actions = { + async workspace(page, name) { await domClick(page.locator(".workspace-tabs button", { hasText: name })); await page.waitForTimeout(500); }, + async openStudio(page) { + await page.goto(`${baseUrl}/?ui=web`); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); + await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); + await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + }, + async enterEdit(page) { + const mode = page.locator(".mode-chip button").first(); + if (/Edit Mode/.test(await mode.innerText().catch(() => ""))) return; + await domClick(mode); + const item = page.getByRole("menuitemcheckbox", { name: /编辑模式/ }).first(); + await item.waitFor({ timeout: 15_000 }); await item.click(); await page.waitForTimeout(900); + }, + async assignReMaterial(page) { + await actions.workspace(page, "Shading"); + await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 }); + await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1"); + await page.waitForFunction(() => document.querySelectorAll('[data-library="library:re-pbr-v1"]').length === 6, null, { timeout: 60_000 }); + await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick(); + await page.waitForFunction(() => { const slot = document.querySelector('select[aria-label^="材质槽"]'); return slot instanceof HTMLSelectElement && slot.value.includes("material:black_tiles_wall"); }, null, { timeout: 120_000 }); + }, + async importTexture(page) { + await page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first().setInputFiles(TEXTURE); + await page.waitForFunction(() => Array.from(document.querySelectorAll('select[aria-label="材质图片"] option')).some((option) => option.textContent?.includes("re-texture-base")), null, { timeout: 60_000 }); + await page.locator('select[aria-label="材质图片"]').first().selectOption({ label: "re-texture-base" }); + }, + async uv(page) { + await actions.workspace(page, "UV Editing"); + await page.locator(".uv-controls").waitFor({ timeout: 60_000 }); + await actions.enterEdit(page); await page.keyboard.press("Escape"); + await domClick(page.locator(".wasm-uv-editor button", { hasText: "全选 UV" }).first()); + await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("UV 选择已提交"), null, { timeout: 60_000 }); + await domClick(page.locator(".wasm-uv-editor button", { hasText: "展开所选 UV" }).first()); + await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("展开 UV已提交"), null, { timeout: 60_000 }); + }, + async paint(page) { + await actions.workspace(page, "Texture Paint"); + const select = page.locator('.texture-paint-view select[aria-label="纹理图像"]').first(); + await select.waitFor({ timeout: 60_000 }); + const options = await select.locator("option").allTextContents(); + const match = options.find((text) => text.includes("re-texture-base")); + if (match) await select.selectOption({ label: match }); + await page.waitForTimeout(700); + const canvas = page.getByTestId("texture-paint-canvas"); + const box = await canvas.boundingBox(); if (!box) throw new Error("paint canvas has no box"); + await page.mouse.move(box.x + box.width * .35, box.y + box.height * .45); await page.mouse.down(); + await page.mouse.move(box.x + box.width * .62, box.y + box.height * .55, { steps: 18 }); await page.mouse.up(); + await page.waitForTimeout(1200); + }, + async compositor(page) { + await actions.workspace(page, "Compositing"); + const canvas = page.getByTestId("compositor-canvas"); await canvas.waitFor({ timeout: 60_000 }); + const add = canvas.getByLabel("添加节点类型"); + await add.selectOption("CONSTANT_COLOR"); await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); await page.waitForTimeout(400); + await add.selectOption("EXPOSURE"); await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); await page.waitForTimeout(400); + await drag(page, page.locator('.react-flow__node[data-id="node-constant-color-1"] .react-flow__handle.source').first(), page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.target').first()); + await drag(page, page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.source').first(), page.locator('.react-flow__node[data-id="node-composite"] .react-flow__handle.target').first()); + await domClick(canvas.getByRole("button", { name: "执行合成", exact: true })); await page.waitForTimeout(600); + await domClick(canvas.getByRole("button", { name: "提交图", exact: true })); await page.waitForTimeout(1200); + }, + async geonodes(page) { + await actions.workspace(page, "Geometry Nodes"); + const canvas = page.getByTestId("geometry-node-canvas"); await canvas.waitFor({ timeout: 60_000 }); + await canvas.getByLabel("添加节点类型").selectOption({ index: 2 }).catch(() => undefined); + await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); await page.waitForTimeout(400); + await drag(page, page.locator('.react-flow__node[data-id="group-input"] .react-flow__handle.source').first(), page.locator('.react-flow__node[data-id="node-geometrynodetransform"] .react-flow__handle.target').first()); + await domClick(canvas.getByRole("button", { name: "提交图", exact: true })); await page.waitForTimeout(1200); + }, + async spreadsheet(page) { + const sheet = page.getByTestId("spreadsheet-view"); await sheet.waitFor({ timeout: 60_000 }); + await sheet.getByLabel("数据源").selectOption("vertices"); await page.waitForTimeout(500); + await sheet.getByLabel("过滤行").fill("0.5"); await page.waitForTimeout(500); + await sheet.getByLabel("过滤行").fill(""); + }, + async saveOpfs(page) { + await domClick(page.locator('button[aria-label="保存项目"]')); + await page.waitForFunction(() => document.querySelector("main.blender-app")?.getAttribute("data-user-action-save-status") === "SUCCEEDED", null, { timeout: 60_000 }); + }, + async reopen(page) { + await page.reload(); + await page.locator("main.blender-app").waitFor({ timeout: 90_000 }); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="recent-projects"] option').length > 1, null, { timeout: 60_000 }); + await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 }); + const discard = page.getByRole("button", { name: "不保存,继续", exact: true }); + if (await discard.count()) await discard.click(); + await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + }, + async addKeyframes(page) { + await actions.workspace(page, "Animation"); await page.waitForTimeout(600); + await page.getByLabel("当前帧").fill("12"); await page.waitForTimeout(400); + await domClick(page.getByRole("button", { name: "插入位置关键帧", exact: true })); + await page.waitForTimeout(500); + await page.getByLabel("当前帧").fill("48"); await page.waitForTimeout(400); + await domClick(page.getByRole("button", { name: "插入位置关键帧", exact: true })); + await page.waitForTimeout(800); + }, +}; + +const MODULE_STEPS = { + M01: ["intro", "open", "save", "summary"], + M02: ["intro", "select", "navigate", "search", "summary"], + M03: ["intro", "edit", "selectall", "extrude", "summary"], + M04: ["intro", "open", "modifiers", "summary"], + M05: ["intro", "shading", "assign", "summary"], + M06: ["intro", "uv", "summary"], + M07: ["intro", "paint", "summary"], + M08: ["intro", "render", "summary"], + M09: ["intro", "result", "summary"], + M10: ["intro", "keys", "summary"], + M11: ["intro", "graph", "summary"], + M12: ["intro", "graph", "summary"], + M13: ["intro", "sheet", "summary"], + M14: ["intro", "save", "reload", "verify", "summary"], + M15: ["intro", "scripting", "summary"], +}; + +async function runModule(module, page) { + const stepIds = MODULE_STEPS[module.id] ?? ["intro", "summary"]; + const chapters = narrationChapters(module, stepIds); + const out = path.join(GOLDEN, module.id); + fs.mkdirSync(out, { recursive: true }); + const chaptersFile = path.join(out, "chapters.json"); + fs.writeFileSync(chaptersFile, JSON.stringify({ schemaVersion: 1, voice: "zh-CN-XiaoxiaoNeural", chapters }, null, 2) + "\n"); + const preparation = spawnSync(PYTHON, ["-I", NARRATION, "--mode", "prepare", "--chapters", chaptersFile, "--out", out], { encoding: "utf8" }); + if (preparation.status !== 0) throw new Error(`narration prepare failed: ${preparation.stderr || preparation.stdout}`); + const narration = JSON.parse(fs.readFileSync(path.join(out, "narration.json"), "utf8")); + const durationOf = (id) => narration.chapters.find((chapter) => chapter.id === id)?.durationMs ?? 8000; + const recordingStart = Date.now(); + const timeline = []; + for (const chapter of chapters) { + const startMs = Date.now() - recordingStart; + await page.evaluate((text) => window.__lessonSetCaption?.(text), chapter.title); + try { await chapter.run(page); await installOverlay(page).catch(() => undefined); } + catch (error) { await page.screenshot({ path: path.join(out, `failure-${chapter.id}.png`) }).catch(() => undefined); throw error; } + const waitMs = Math.max(0, startMs + durationOf(chapter.id) + 3500 - (Date.now() - recordingStart)); + if (waitMs > 0) await page.waitForTimeout(waitMs); + timeline.push({ id: chapter.id, title: chapter.title, startMs, endMs: Date.now() - recordingStart }); + } + const elapsed = Date.now() - recordingStart; + if (elapsed < 187_000) await page.waitForTimeout(187_000 - elapsed); + return { out, timeline, durationMs: Date.now() - recordingStart, chapters }; +} + +function narrationChapters(module, stepIds) { + const steps = module.steps; + const intro = `本模块是${module.id}:${module.title}。${module.summary}目标是:${module.objectives.join(";")}。`; + const summary = `本模块小结。${(module.tips ?? []).join(" ")} ${(module.warnings ?? []).join(" ")} 保存仍使用浏览器 OPFS,需要本地文件时再手动下载。`; + const actionFor = (id) => (page) => { + switch (module.id + ":" + id) { + case "M01:open": return actions.openStudio(page); + case "M01:save": return actions.saveOpfs(page); + case "M02:select": return (async () => { await actions.workspace(page, "Layout"); await page.locator(".tree-row.child, [role=treeitem]").first().click().catch(() => undefined); await page.waitForTimeout(500); })(); + case "M02:navigate": return (async () => { const canvas = page.locator(".viewport-area canvas.viewport-canvas").first(); const box = await canvas.boundingBox(); if (box) { await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down({ button: "middle" }); await page.mouse.move(box.x + box.width / 2 + 60, box.y + box.height / 2 + 30, { steps: 10 }); await page.mouse.up({ button: "middle" }); } })(); + case "M02:search": return (async () => { await domClick(page.getByRole("button", { name: "操作搜索" })); await page.waitForTimeout(300); await page.keyboard.type("移动"); await page.waitForTimeout(500); await page.keyboard.press("Escape"); })(); + case "M03:edit": return (async () => { await actions.workspace(page, "Modeling"); await actions.enterEdit(page); })(); + case "M03:selectall": return domClick(page.getByRole("button", { name: "全选", exact: true }).first()); + case "M03:extrude": return (async () => { await domClick(page.getByRole("button", { name: "EXTRUDE", exact: true })); await page.waitForTimeout(700); await page.getByRole("button", { name: "撤销", exact: true }).click().catch(() => undefined); })(); + case "M04:open": return actions.openStudio(page); + case "M04:modifiers": return (async () => { await actions.workspace(page, "Layout"); await page.getByText("Modifiers", { exact: true }).first().scrollIntoViewIfNeeded().catch(() => undefined); await page.waitForTimeout(500); })(); + case "M05:shading": return actions.workspace(page, "Shading"); + case "M05:assign": return actions.assignReMaterial(page); + case "M06:uv": return actions.uv(page); + case "M07:paint": return (async () => { await actions.assignReMaterial(page); await actions.importTexture(page); await actions.paint(page); })(); + case "M08:render": return (async () => { await actions.workspace(page, "Rendering"); await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024").catch(() => undefined); await page.waitForTimeout(400); const render = page.getByRole("button", { name: "渲染 PBR", exact: true }).first(); if (await render.count()) { await domClick(render); await page.waitForFunction(() => { const image = document.querySelector('.pbr-render-result img'); return image instanceof HTMLImageElement && image.naturalWidth >= 1024; }, null, { timeout: 180_000 }).catch(() => undefined); } })(); + case "M09:result": return actions.workspace(page, "Rendering"); + case "M10:keys": return actions.addKeyframes(page); + case "M11:graph": return actions.compositor(page); + case "M12:graph": return actions.geonodes(page); + case "M13:sheet": return actions.spreadsheet(page); + case "M14:save": return actions.saveOpfs(page); + case "M14:reload": return actions.reopen(page); + case "M14:verify": return (async () => { await page.waitForTimeout(600); })(); + case "M15:scripting": return actions.workspace(page, "Scripting"); + default: return () => page.waitForTimeout(500); + } + }; + const actionIds = stepIds.filter((id) => id !== "intro" && id !== "summary"); + const chapterNarration = (id, index) => { + if (id === "intro") return intro; + if (id === "summary") return summary; + const actionIndex = Math.max(0, actionIds.indexOf(id)); + if (actionIds.length <= 1) return steps.map((step) => step.text).join(" "); + const perChapter = Math.ceil(steps.length / actionIds.length); + const slice = steps.slice(actionIndex * perChapter, (actionIndex + 1) * perChapter); + return (slice.length ? slice : steps.slice(-perChapter)).map((step) => step.text).join(" "); + }; + return stepIds.map((id, index) => ({ + id, title: id === "intro" ? `${module.id} 目标` : id === "summary" ? `${module.id} 小结` : (steps[Math.min(index - 1, steps.length - 1)]?.text ?? "").slice(0, 28), + narration: chapterNarration(id, index), + run: actionFor(id), + })); +} + +async function main() { + await waitForServer(); + fs.mkdirSync(GOLDEN, { recursive: true }); + fs.mkdirSync(PUBLIC_VIDEO, { recursive: true }); + const reports = []; + for (const moduleId of requested) { + const module = COURSE.modules.find((candidate) => candidate.id === moduleId); + if (!module) { reports.push({ moduleId, status: "MISSING" }); continue; } + const finalName = `${module.id}-lesson-720p.mp4`; + const finalPath = path.join(PUBLIC_VIDEO, finalName); + if (resume && fs.existsSync(finalPath)) { reports.push({ moduleId, status: "SKIPPED", video: finalPath }); continue; } + const browser = await chromium.launch({ headless: false, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", args: gpuMode === "hardware" ? hardwareArgs : ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); + const context = await browser.newContext({ viewport: { width: 1600, height: 900 }, acceptDownloads: true, recordVideo: { dir: path.join(GOLDEN, moduleId), size: { width: 1600, height: 900 } } }); + const page = await context.newPage(); + const video = page.video(); + page.on("pageerror", (error) => console.error(`${moduleId} pageerror`, error.message)); + try { + await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" }); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await installOverlay(page); + const result = await runModule(module, page); + await context.close(); + const webm = path.join(result.out, "screencast.webm"); + await video?.saveAs(webm).catch(() => undefined); + fs.writeFileSync(path.join(result.out, "timeline.json"), JSON.stringify({ schemaVersion: 1, status: "RECORDED", durationMs: result.durationMs, chapters: result.timeline }, null, 2) + "\n"); + const mixed = spawnSync(PYTHON, ["-I", NARRATION, "--mode", "mix", "--out", result.out, "--video", finalPath], { encoding: "utf8" }); + if (mixed.status !== 0) throw new Error(`mix failed: ${mixed.stderr || mixed.stdout}`); + const rename = (from, to) => { if (fs.existsSync(from)) fs.renameSync(from, to); }; + rename(path.join(PUBLIC_VIDEO, "re-pbr-lesson.zh-Hans.vtt"), path.join(PUBLIC_VIDEO, `${module.id}-lesson.zh-Hans.vtt`)); + const posterPath = path.join(PUBLIC_VIDEO, `${module.id}-lesson-poster.jpg`); + const posterSeconds = Math.max(5, Math.min(Math.floor(result.durationMs / 1000 * 0.25), Math.floor(result.durationMs / 1000) - 5)); + spawnSync("/usr/bin/ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", "-ss", String(posterSeconds), "-i", finalPath, "-frames:v", "1", "-vf", "scale=1280:720", posterPath], { encoding: "utf8" }); + fs.rmSync(path.join(PUBLIC_VIDEO, "poster.jpg"), { force: true }); + reports.push({ moduleId, status: "PASS", video: finalPath, durationMs: result.durationMs }); + console.log(`${moduleId} PASS ${Math.round(result.durationMs / 1000)}s`); + } + catch (error) { + reports.push({ moduleId, status: "FAIL", error: error instanceof Error ? error.message : String(error) }); + console.error(`${moduleId} FAIL`, error); + await context.close().catch(() => undefined); + } + finally { await browser.close().catch(() => undefined); } + } + fs.writeFileSync(path.join(GOLDEN, "record-report.json"), JSON.stringify({ schemaVersion: 1, generatedAt: new Date().toISOString(), reports }, null, 2) + "\n"); + console.log(JSON.stringify({ modules: reports.length, failed: reports.filter((report) => report.status === "FAIL").length, reports }, null, 2)); + if (reports.some((report) => report.status === "FAIL")) process.exitCode = 1; +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/web/protocol/ui-schema.ts b/web/protocol/ui-schema.ts index fe8f8b31b..0a9f44987 100644 --- a/web/protocol/ui-schema.ts +++ b/web/protocol/ui-schema.ts @@ -1,4 +1,3 @@ -import {applyAreaOperation} from "./area-layout"; export const BLENDER_WORKSPACE_IDS = [ "Layout", "Modeling", @@ -87,9 +86,7 @@ export type UICommand = | { type: "setActiveArea"; areaId: string } | { type: "toggleOperatorSearch"; open?: boolean } | { type: "toggleMenu"; menu?: string } - | { type: "toggleSidebar"; visible?: boolean } - | { type: "areaOperation"; workspaceId?: WorkspaceId; operation: import("./area-layout").AreaOperation; measured?: AreaIR[] } - | { type: "setWorkspaceLayout"; workspaceId: WorkspaceId; areas: AreaIR[] }; + | { type: "toggleSidebar"; visible?: boolean }; function regions(): RegionIR[] { return [ @@ -233,31 +230,5 @@ export function reduceUICommand(state: WebWorkspaceState, command: UICommand): W return { ...state, openMenu: state.openMenu === command.menu ? null : command.menu ?? null, operatorSearchOpen: null === command.menu ? state.operatorSearchOpen : false }; case "toggleSidebar": return { ...state, sidebarVisible: command.visible ?? !state.sidebarVisible }; - case "areaOperation": { - const workspaceId = command.workspaceId ?? state.context.workspaceId; - const workspace = state.workspaces[workspaceId]; - if (!workspace) return state; - const base = command.measured && command.measured.length > 0 - ? { ...workspace, areas: command.measured, layoutMode: "custom" as const, revision: workspace.revision + 1 } - : workspace; - const next = applyAreaOperation({ ...base, layoutMode: "custom" }, command.operation); - return { - ...state, - workspaces: { ...state.workspaces, [workspaceId]: next }, - context: { - ...state.context, - workspaceId, - activeAreaId: next.activeAreaId, - activeEditor: next.areas.find((area) => area.id === next.activeAreaId)?.editor ?? state.context.activeEditor, - revision: state.context.revision + 1, - }, - }; - } - case "setWorkspaceLayout": { - const workspace = state.workspaces[command.workspaceId]; - if (!workspace) return state; - const next = { ...workspace, areas: command.areas, layoutMode: "custom" as const, revision: workspace.revision + 1 }; - return { ...state, workspaces: { ...state.workspaces, [command.workspaceId]: next } }; - } } }