| @@ -4,3 +4,5 @@ artifacts/runs/ | |||
| artifacts/deploy-smoke/ | |||
| playwright-report/ | |||
| test-results/ | |||
| reports/**/*.log | |||
| **/__pycache__/ | |||
| @@ -0,0 +1,17 @@ | |||
| # 三位教员 v2 更新与旧版备份 | |||
| 更新于 2026-09-15,报告目录沿用任务开始日期 20260914。 | |||
| - 本机旧形象包:本目录 `backup-local/`,按三个原编号保存。 | |||
| - 本机旧封面:本目录 `backup-covers/`。 | |||
| - 本机新形象包:本目录 `generated/`;已复制到 `ai_person/ai_person_service/builtin_avatars/` 中相同编号目录。 | |||
| - 服务器旧形象包与元数据:`/home/digital-human-service/data/backups/instructors-v2-20260914/`,包括 `builtin/`、`runtime/`、`before-metadata.json`。 | |||
| - 服务器源码与已发布封面备份:`/home/digital-human-service/releases/instructors-v2-20260914/backup-host/`、`backup-covers/`。 | |||
| - 旧服务镜像:`digital-human-service:before-instructors-v2-20260914`。 | |||
| - 新服务镜像:`digital-human-service:instructors-v2-20260914`;`digital-human-service:cpu` 同样指向新镜像。 | |||
| 需要恢复时,分别恢复三个原编号下的源码包、运行包与封面,并将服务镜像标签切回旧镜像;元数据通过 `app.database.upsert_builtin_avatar` 按备份参数恢复。不要删除或重新创建平台形象记录。 | |||
| 本次沿用既有平台记录,没有调用全量“同步服务资源”:该接口还会重新导入服务中保留的旧样例。平台维度、帧数、时长仍与新资源一致,默认教员2和声音配置保持不变。临时生成的三个资源已通过服务 DELETE 接口清理,生成日志、任务号、源文件摘要与最终资源均保留在报告中。 | |||
| 核验了三个原入口的 Viewer、约8秒语音播放起止、嘴部驱动、运行包与镜像包哈希、原平台记录及默认配置;未把这些检查当成逐音节口型质量验收。已生成的39个讲解视频与原有WAV没有改动。 | |||
| @@ -0,0 +1,128 @@ | |||
| { | |||
| "code": 0, | |||
| "message": "success", | |||
| "data": { | |||
| "items": [ | |||
| { | |||
| "id": "a1000000000000000000000000000007", | |||
| "dataVersion": 9, | |||
| "name": "教员3", | |||
| "status": "ready", | |||
| "bundleReady": true, | |||
| "engineAuthorized": true, | |||
| "owner": "系统管理员", | |||
| "ownerId": "1", | |||
| "ownerName": "系统管理员", | |||
| "sourceType": "built_in", | |||
| "role": "装备维修教员", | |||
| "specialty": "装备维修教学", | |||
| "gender": "男", | |||
| "visibility": "internal", | |||
| "builtIn": true, | |||
| "default": false, | |||
| "voiceCapabilityId": "builtin-voice-male-gentle", | |||
| "voice": { | |||
| "capabilityId": "builtin-voice-male-gentle", | |||
| "speaker": 1, | |||
| "speed": 0.8, | |||
| "name": "温和男声" | |||
| }, | |||
| "frameCount": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20080, | |||
| "errorMessage": null, | |||
| "coverFileCode": null, | |||
| "coverUrl": "/avatars/builtin/equipment-instructor-3.png", | |||
| "previewFileCode": null, | |||
| "previewUrl": null, | |||
| "viewerUrl": "/human/viewer/a1000000000000000000000000000007", | |||
| "manifestUrl": "/human/api/v1/avatars/a1000000000000000000000000000007/assets/manifest.json", | |||
| "createdAt": "2026-08-29T13:42:19", | |||
| "updatedAt": "2026-08-29T17:09:14" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000006", | |||
| "dataVersion": 10, | |||
| "name": "教员2", | |||
| "status": "ready", | |||
| "bundleReady": true, | |||
| "engineAuthorized": true, | |||
| "owner": "系统管理员", | |||
| "ownerId": "1", | |||
| "ownerName": "系统管理员", | |||
| "sourceType": "built_in", | |||
| "role": "装备维修教员", | |||
| "specialty": "装备维修教学", | |||
| "gender": "男", | |||
| "visibility": "internal", | |||
| "builtIn": true, | |||
| "default": true, | |||
| "voiceCapabilityId": "builtin-voice-male-gentle", | |||
| "voice": { | |||
| "capabilityId": "builtin-voice-male-gentle", | |||
| "speaker": 1, | |||
| "speed": 0.8, | |||
| "name": "温和男声" | |||
| }, | |||
| "frameCount": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20080, | |||
| "errorMessage": null, | |||
| "coverFileCode": null, | |||
| "coverUrl": "/avatars/builtin/equipment-instructor-2.png", | |||
| "previewFileCode": null, | |||
| "previewUrl": null, | |||
| "viewerUrl": "/human/viewer/a1000000000000000000000000000006", | |||
| "manifestUrl": "/human/api/v1/avatars/a1000000000000000000000000000006/assets/manifest.json", | |||
| "createdAt": "2026-08-29T13:42:17", | |||
| "updatedAt": "2026-08-29T17:09:14" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000005", | |||
| "dataVersion": 8, | |||
| "name": "教员1", | |||
| "status": "ready", | |||
| "bundleReady": true, | |||
| "engineAuthorized": true, | |||
| "owner": "系统管理员", | |||
| "ownerId": "1", | |||
| "ownerName": "系统管理员", | |||
| "sourceType": "built_in", | |||
| "role": "装备维修教员", | |||
| "specialty": "装备维修教学", | |||
| "gender": "男", | |||
| "visibility": "internal", | |||
| "builtIn": true, | |||
| "default": false, | |||
| "voiceCapabilityId": "builtin-voice-male-gentle", | |||
| "voice": { | |||
| "capabilityId": "builtin-voice-male-gentle", | |||
| "speaker": 1, | |||
| "speed": 0.8, | |||
| "name": "温和男声" | |||
| }, | |||
| "frameCount": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20080, | |||
| "errorMessage": null, | |||
| "coverFileCode": null, | |||
| "coverUrl": "/avatars/builtin/equipment-instructor-1.png", | |||
| "previewFileCode": null, | |||
| "previewUrl": null, | |||
| "viewerUrl": "/human/viewer/a1000000000000000000000000000005", | |||
| "manifestUrl": "/human/api/v1/avatars/a1000000000000000000000000000005/assets/manifest.json", | |||
| "createdAt": "2026-08-29T13:42:15", | |||
| "updatedAt": "2026-08-29T17:09:14" | |||
| } | |||
| ], | |||
| "page": 1, | |||
| "pageSize": 200, | |||
| "total": 3, | |||
| "pages": 1 | |||
| }, | |||
| "timestamp": 1789402143513, | |||
| "requestId": "3b9595df92c9459999719604d49bbac7" | |||
| } | |||
| @@ -0,0 +1,59 @@ | |||
| [ | |||
| { | |||
| "id": "a1000000000000000000000000000005", | |||
| "name": "教员1", | |||
| "status": "ready", | |||
| "source_filename": "built_in:equipment-instructor-1", | |||
| "resize": true, | |||
| "matting_mode": "off", | |||
| "device": "cpu", | |||
| "frame_count": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20.08, | |||
| "error": "", | |||
| "created_at": "2026-08-30T16:30:15.680802+00:00", | |||
| "updated_at": "2026-09-14T16:05:16.360768+00:00", | |||
| "thumbnail_url": "/api/v1/avatars/a1000000000000000000000000000005/assets/thumbnail.jpg", | |||
| "viewer_url": "/viewer/a1000000000000000000000000000005", | |||
| "manifest_url": "/api/v1/avatars/a1000000000000000000000000000005/assets/manifest.json" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000006", | |||
| "name": "教员2", | |||
| "status": "ready", | |||
| "source_filename": "built_in:equipment-instructor-2", | |||
| "resize": true, | |||
| "matting_mode": "off", | |||
| "device": "cpu", | |||
| "frame_count": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20.08, | |||
| "error": "", | |||
| "created_at": "2026-08-30T16:30:15.688395+00:00", | |||
| "updated_at": "2026-09-14T16:05:16.388739+00:00", | |||
| "thumbnail_url": "/api/v1/avatars/a1000000000000000000000000000006/assets/thumbnail.jpg", | |||
| "viewer_url": "/viewer/a1000000000000000000000000000006", | |||
| "manifest_url": "/api/v1/avatars/a1000000000000000000000000000006/assets/manifest.json" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000007", | |||
| "name": "教员3", | |||
| "status": "ready", | |||
| "source_filename": "built_in:equipment-instructor-3", | |||
| "resize": true, | |||
| "matting_mode": "off", | |||
| "device": "cpu", | |||
| "frame_count": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20.08, | |||
| "error": "", | |||
| "created_at": "2026-08-30T16:30:15.696651+00:00", | |||
| "updated_at": "2026-09-14T16:05:16.422542+00:00", | |||
| "thumbnail_url": "/api/v1/avatars/a1000000000000000000000000000007/assets/thumbnail.jpg", | |||
| "viewer_url": "/viewer/a1000000000000000000000000000007", | |||
| "manifest_url": "/api/v1/avatars/a1000000000000000000000000000007/assets/manifest.json" | |||
| } | |||
| ] | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "version": 1, | |||
| "created_at": "2026-08-29T08:35:53.172587+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 4291902, | |||
| "combined_data.json.gz": 154401, | |||
| "thumbnail.jpg": 34112 | |||
| } | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "version": 1, | |||
| "created_at": "2026-08-29T08:37:30.778752+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 3568366, | |||
| "combined_data.json.gz": 179829, | |||
| "thumbnail.jpg": 33782 | |||
| } | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "version": 1, | |||
| "created_at": "2026-08-29T08:39:03.216895+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 4593812, | |||
| "combined_data.json.gz": 165007, | |||
| "thumbnail.jpg": 34187 | |||
| } | |||
| } | |||
| @@ -0,0 +1,128 @@ | |||
| { | |||
| "code": 0, | |||
| "message": "success", | |||
| "data": { | |||
| "items": [ | |||
| { | |||
| "id": "a1000000000000000000000000000007", | |||
| "dataVersion": 9, | |||
| "name": "教员3", | |||
| "status": "ready", | |||
| "bundleReady": true, | |||
| "engineAuthorized": true, | |||
| "owner": "系统管理员", | |||
| "ownerId": "1", | |||
| "ownerName": "系统管理员", | |||
| "sourceType": "built_in", | |||
| "role": "装备维修教员", | |||
| "specialty": "装备维修教学", | |||
| "gender": "男", | |||
| "visibility": "internal", | |||
| "builtIn": true, | |||
| "default": false, | |||
| "voiceCapabilityId": "builtin-voice-male-gentle", | |||
| "voice": { | |||
| "capabilityId": "builtin-voice-male-gentle", | |||
| "speaker": 1, | |||
| "speed": 0.8, | |||
| "name": "温和男声" | |||
| }, | |||
| "frameCount": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20080, | |||
| "errorMessage": null, | |||
| "coverFileCode": null, | |||
| "coverUrl": "/avatars/builtin/equipment-instructor-3.png", | |||
| "previewFileCode": null, | |||
| "previewUrl": null, | |||
| "viewerUrl": "/human/viewer/a1000000000000000000000000000007", | |||
| "manifestUrl": "/human/api/v1/avatars/a1000000000000000000000000000007/assets/manifest.json", | |||
| "createdAt": "2026-08-29T13:42:19", | |||
| "updatedAt": "2026-08-29T17:09:14" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000006", | |||
| "dataVersion": 10, | |||
| "name": "教员2", | |||
| "status": "ready", | |||
| "bundleReady": true, | |||
| "engineAuthorized": true, | |||
| "owner": "系统管理员", | |||
| "ownerId": "1", | |||
| "ownerName": "系统管理员", | |||
| "sourceType": "built_in", | |||
| "role": "装备维修教员", | |||
| "specialty": "装备维修教学", | |||
| "gender": "男", | |||
| "visibility": "internal", | |||
| "builtIn": true, | |||
| "default": true, | |||
| "voiceCapabilityId": "builtin-voice-male-gentle", | |||
| "voice": { | |||
| "capabilityId": "builtin-voice-male-gentle", | |||
| "speaker": 1, | |||
| "speed": 0.8, | |||
| "name": "温和男声" | |||
| }, | |||
| "frameCount": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20080, | |||
| "errorMessage": null, | |||
| "coverFileCode": null, | |||
| "coverUrl": "/avatars/builtin/equipment-instructor-2.png", | |||
| "previewFileCode": null, | |||
| "previewUrl": null, | |||
| "viewerUrl": "/human/viewer/a1000000000000000000000000000006", | |||
| "manifestUrl": "/human/api/v1/avatars/a1000000000000000000000000000006/assets/manifest.json", | |||
| "createdAt": "2026-08-29T13:42:17", | |||
| "updatedAt": "2026-08-29T17:09:14" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000005", | |||
| "dataVersion": 8, | |||
| "name": "教员1", | |||
| "status": "ready", | |||
| "bundleReady": true, | |||
| "engineAuthorized": true, | |||
| "owner": "系统管理员", | |||
| "ownerId": "1", | |||
| "ownerName": "系统管理员", | |||
| "sourceType": "built_in", | |||
| "role": "装备维修教员", | |||
| "specialty": "装备维修教学", | |||
| "gender": "男", | |||
| "visibility": "internal", | |||
| "builtIn": true, | |||
| "default": false, | |||
| "voiceCapabilityId": "builtin-voice-male-gentle", | |||
| "voice": { | |||
| "capabilityId": "builtin-voice-male-gentle", | |||
| "speaker": 1, | |||
| "speed": 0.8, | |||
| "name": "温和男声" | |||
| }, | |||
| "frameCount": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20080, | |||
| "errorMessage": null, | |||
| "coverFileCode": null, | |||
| "coverUrl": "/avatars/builtin/equipment-instructor-1.png", | |||
| "previewFileCode": null, | |||
| "previewUrl": null, | |||
| "viewerUrl": "/human/viewer/a1000000000000000000000000000005", | |||
| "manifestUrl": "/human/api/v1/avatars/a1000000000000000000000000000005/assets/manifest.json", | |||
| "createdAt": "2026-08-29T13:42:15", | |||
| "updatedAt": "2026-08-29T17:09:14" | |||
| } | |||
| ], | |||
| "page": 1, | |||
| "pageSize": 200, | |||
| "total": 3, | |||
| "pages": 1 | |||
| }, | |||
| "timestamp": 1789401373462, | |||
| "requestId": "a1f1fb85dcc44405be3c0971fc59131c" | |||
| } | |||
| @@ -0,0 +1,59 @@ | |||
| [ | |||
| { | |||
| "id": "a1000000000000000000000000000005", | |||
| "name": "教员1", | |||
| "status": "ready", | |||
| "source_filename": "built_in:equipment-instructor-1", | |||
| "resize": true, | |||
| "matting_mode": "off", | |||
| "device": "cpu", | |||
| "frame_count": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20.08, | |||
| "error": "", | |||
| "created_at": "2026-08-30T16:30:15.680802+00:00", | |||
| "updated_at": "2026-09-01T14:18:39.459243+00:00", | |||
| "thumbnail_url": "/api/v1/avatars/a1000000000000000000000000000005/assets/thumbnail.jpg", | |||
| "viewer_url": "/viewer/a1000000000000000000000000000005", | |||
| "manifest_url": "/api/v1/avatars/a1000000000000000000000000000005/assets/manifest.json" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000006", | |||
| "name": "教员2", | |||
| "status": "ready", | |||
| "source_filename": "built_in:equipment-instructor-2", | |||
| "resize": true, | |||
| "matting_mode": "off", | |||
| "device": "cpu", | |||
| "frame_count": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20.08, | |||
| "error": "", | |||
| "created_at": "2026-08-30T16:30:15.688395+00:00", | |||
| "updated_at": "2026-09-01T14:18:39.494195+00:00", | |||
| "thumbnail_url": "/api/v1/avatars/a1000000000000000000000000000006/assets/thumbnail.jpg", | |||
| "viewer_url": "/viewer/a1000000000000000000000000000006", | |||
| "manifest_url": "/api/v1/avatars/a1000000000000000000000000000006/assets/manifest.json" | |||
| }, | |||
| { | |||
| "id": "a1000000000000000000000000000007", | |||
| "name": "教员3", | |||
| "status": "ready", | |||
| "source_filename": "built_in:equipment-instructor-3", | |||
| "resize": true, | |||
| "matting_mode": "off", | |||
| "device": "cpu", | |||
| "frame_count": 502, | |||
| "width": 720, | |||
| "height": 960, | |||
| "duration": 20.08, | |||
| "error": "", | |||
| "created_at": "2026-08-30T16:30:15.696651+00:00", | |||
| "updated_at": "2026-09-01T14:18:39.543021+00:00", | |||
| "thumbnail_url": "/api/v1/avatars/a1000000000000000000000000000007/assets/thumbnail.jpg", | |||
| "viewer_url": "/viewer/a1000000000000000000000000000007", | |||
| "manifest_url": "/api/v1/avatars/a1000000000000000000000000000007/assets/manifest.json" | |||
| } | |||
| ] | |||
| @@ -0,0 +1,22 @@ | |||
| { | |||
| "count": 3, | |||
| "defaultInstructor": 2, | |||
| "previews": [ | |||
| { | |||
| "number": 1, | |||
| "viewer": "/human/viewer/a1000000000000000000000000000005", | |||
| "ready": true | |||
| }, | |||
| { | |||
| "number": 2, | |||
| "viewer": "/human/viewer/a1000000000000000000000000000006", | |||
| "ready": true | |||
| }, | |||
| { | |||
| "number": 3, | |||
| "viewer": "/human/viewer/a1000000000000000000000000000007", | |||
| "ready": true | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| { | |||
| "identitiesPreserved": true, | |||
| "defaultInstructor": 2, | |||
| "voicesPreserved": true, | |||
| "platformCount": 3, | |||
| "runtimeAssetHashesMatch": true, | |||
| "imageAssetHashesMatch": true, | |||
| "localAndPublishedCoversUpdated": true, | |||
| "temporaryAvatarsRemoved": [ | |||
| "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "73339bc579334a8abf2696d9c4944b87", | |||
| "15606bd2b60a4d11951c21907968c3dc" | |||
| ], | |||
| "oldLectureVideosAndAudioPreserved": true, | |||
| "scope": "Resource regeneration and runtime playback verified; phoneme-level lip sync quality not accepted by this check." | |||
| } | |||
| @@ -0,0 +1,92 @@ | |||
| [ | |||
| { | |||
| "number": 1, | |||
| "stable_id": "a1000000000000000000000000000005", | |||
| "generated_id": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "manifest": { | |||
| "version": 1, | |||
| "created_at": "2026-09-14T15:57:38.109202+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 3959109, | |||
| "combined_data.json.gz": 147484, | |||
| "thumbnail.jpg": 31865 | |||
| } | |||
| }, | |||
| "geometry_type": "dict", | |||
| "sha256": { | |||
| "01.mp4": "001912f1bfd2242b22adb473d3e94ce1c5781085c43ca1184db0660adc26c521", | |||
| "combined_data.json.gz": "7b00847d2ed6eacebe58fe5b43a8e27fb6efea252af743c2aebaaa1cfe430d79", | |||
| "thumbnail.jpg": "67a5e13059a48b06b0a6e7b80cd7d9b72d0bb6afc6aba449432a51190e6e7733", | |||
| "manifest.json": "8155a21af68b60ea024c7591c8ad454aa9884a2c7d22cb069c16bb192bc4e122" | |||
| } | |||
| }, | |||
| { | |||
| "number": 2, | |||
| "stable_id": "a1000000000000000000000000000006", | |||
| "generated_id": "73339bc579334a8abf2696d9c4944b87", | |||
| "manifest": { | |||
| "version": 1, | |||
| "created_at": "2026-09-14T15:58:35.155627+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 3644994, | |||
| "combined_data.json.gz": 171386, | |||
| "thumbnail.jpg": 34436 | |||
| } | |||
| }, | |||
| "geometry_type": "dict", | |||
| "sha256": { | |||
| "01.mp4": "2f6b0d0302d6e87db20f71e2a76449a69834f66290795af3655348b920fb9197", | |||
| "combined_data.json.gz": "b347b72949430d72299a1d2333a6c215ec759c6091144569b5c21f9938e64c6b", | |||
| "thumbnail.jpg": "a8ee39c0f0e96ef8878f1218527513b3fb1b675fd46fcc3109197d1b65fdadc2", | |||
| "manifest.json": "a508cf8af21db8148509d6c64667d1dce3587a57fcce4a4d5e66ab1062a9d522" | |||
| } | |||
| }, | |||
| { | |||
| "number": 3, | |||
| "stable_id": "a1000000000000000000000000000007", | |||
| "generated_id": "15606bd2b60a4d11951c21907968c3dc", | |||
| "manifest": { | |||
| "version": 1, | |||
| "created_at": "2026-09-14T15:59:28.067630+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 4111377, | |||
| "combined_data.json.gz": 156012, | |||
| "thumbnail.jpg": 33681 | |||
| } | |||
| }, | |||
| "geometry_type": "dict", | |||
| "sha256": { | |||
| "01.mp4": "e7a0df84b35faba46605d26f3d6ca83e682e964f4f63561d4d370d7a3ce528f1", | |||
| "combined_data.json.gz": "2c313f2a22ae0a8a2848ef6576fba1ac77491fc26c9f0f9dbff72f0660cd46cc", | |||
| "thumbnail.jpg": "fc9848fac6afba7ac4f7f9462abf82a4b97a08416fd7c23fdfed9a224f8dccfe", | |||
| "manifest.json": "7bf78941670b418f003b370ca804c0df89412902bb48996e59a1ec36bd5f7491" | |||
| } | |||
| } | |||
| ] | |||
| @@ -0,0 +1,104 @@ | |||
| [ | |||
| { | |||
| "number": 1, | |||
| "avatarId": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "driver": { | |||
| "frames": 231, | |||
| "active": 231, | |||
| "maxBlend": 53.525753021240234 | |||
| }, | |||
| "events": [ | |||
| { | |||
| "type": "digital-human.ready", | |||
| "avatarId": "b11e725a21c44b8a8499ee76cfbc8737" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-start", | |||
| "avatarId": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "requestId": "v2-validation", | |||
| "duration": 8.256 | |||
| }, | |||
| { | |||
| "type": "digital-human.command-accepted", | |||
| "avatarId": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "requestId": "v2-validation", | |||
| "command": "digital-human.play-audio" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-end", | |||
| "avatarId": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "requestId": "v2-validation", | |||
| "reason": "ended" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| }, | |||
| { | |||
| "number": 2, | |||
| "avatarId": "73339bc579334a8abf2696d9c4944b87", | |||
| "driver": { | |||
| "frames": 221, | |||
| "active": 221, | |||
| "maxBlend": 53.525753021240234 | |||
| }, | |||
| "events": [ | |||
| { | |||
| "type": "digital-human.ready", | |||
| "avatarId": "73339bc579334a8abf2696d9c4944b87" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-start", | |||
| "avatarId": "73339bc579334a8abf2696d9c4944b87", | |||
| "requestId": "v2-validation", | |||
| "duration": 8.256 | |||
| }, | |||
| { | |||
| "type": "digital-human.command-accepted", | |||
| "avatarId": "73339bc579334a8abf2696d9c4944b87", | |||
| "requestId": "v2-validation", | |||
| "command": "digital-human.play-audio" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-end", | |||
| "avatarId": "73339bc579334a8abf2696d9c4944b87", | |||
| "requestId": "v2-validation", | |||
| "reason": "ended" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| }, | |||
| { | |||
| "number": 3, | |||
| "avatarId": "15606bd2b60a4d11951c21907968c3dc", | |||
| "driver": { | |||
| "frames": 213, | |||
| "active": 213, | |||
| "maxBlend": 53.52582931518555 | |||
| }, | |||
| "events": [ | |||
| { | |||
| "type": "digital-human.ready", | |||
| "avatarId": "15606bd2b60a4d11951c21907968c3dc" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-start", | |||
| "avatarId": "15606bd2b60a4d11951c21907968c3dc", | |||
| "requestId": "v2-validation", | |||
| "duration": 8.256 | |||
| }, | |||
| { | |||
| "type": "digital-human.command-accepted", | |||
| "avatarId": "15606bd2b60a4d11951c21907968c3dc", | |||
| "requestId": "v2-validation", | |||
| "command": "digital-human.play-audio" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-end", | |||
| "avatarId": "15606bd2b60a4d11951c21907968c3dc", | |||
| "requestId": "v2-validation", | |||
| "reason": "ended" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| ] | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "version": 1, | |||
| "created_at": "2026-09-14T15:57:38.109202+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 3959109, | |||
| "combined_data.json.gz": 147484, | |||
| "thumbnail.jpg": 31865 | |||
| } | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "version": 1, | |||
| "created_at": "2026-09-14T15:58:35.155627+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 3644994, | |||
| "combined_data.json.gz": 171386, | |||
| "thumbnail.jpg": 34436 | |||
| } | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "version": 1, | |||
| "created_at": "2026-09-14T15:59:28.067630+00:00", | |||
| "device": "cpu", | |||
| "matting_mode": "off", | |||
| "resize": true, | |||
| "source_frame_count": 251, | |||
| "width": 720, | |||
| "height": 960, | |||
| "fps": 25.0, | |||
| "frame_count": 502, | |||
| "duration": 20.08, | |||
| "files": { | |||
| "01.mp4": 4111377, | |||
| "combined_data.json.gz": 156012, | |||
| "thumbnail.jpg": 33681 | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| { | |||
| "a1000000000000000000000000000005": { | |||
| "01.mp4": "001912f1bfd2242b22adb473d3e94ce1c5781085c43ca1184db0660adc26c521", | |||
| "combined_data.json.gz": "7b00847d2ed6eacebe58fe5b43a8e27fb6efea252af743c2aebaaa1cfe430d79", | |||
| "thumbnail.jpg": "67a5e13059a48b06b0a6e7b80cd7d9b72d0bb6afc6aba449432a51190e6e7733", | |||
| "manifest.json": "8155a21af68b60ea024c7591c8ad454aa9884a2c7d22cb069c16bb192bc4e122" | |||
| }, | |||
| "a1000000000000000000000000000006": { | |||
| "01.mp4": "2f6b0d0302d6e87db20f71e2a76449a69834f66290795af3655348b920fb9197", | |||
| "combined_data.json.gz": "b347b72949430d72299a1d2333a6c215ec759c6091144569b5c21f9938e64c6b", | |||
| "thumbnail.jpg": "a8ee39c0f0e96ef8878f1218527513b3fb1b675fd46fcc3109197d1b65fdadc2", | |||
| "manifest.json": "a508cf8af21db8148509d6c64667d1dce3587a57fcce4a4d5e66ab1062a9d522" | |||
| }, | |||
| "a1000000000000000000000000000007": { | |||
| "01.mp4": "e7a0df84b35faba46605d26f3d6ca83e682e964f4f63561d4d370d7a3ce528f1", | |||
| "combined_data.json.gz": "2c313f2a22ae0a8a2848ef6576fba1ac77491fc26c9f0f9dbff72f0660cd46cc", | |||
| "thumbnail.jpg": "fc9848fac6afba7ac4f7f9462abf82a4b97a08416fd7c23fdfed9a224f8dccfe", | |||
| "manifest.json": "7bf78941670b418f003b370ca804c0df89412902bb48996e59a1ec36bd5f7491" | |||
| } | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |||
| <title>三位教员 v2 资源更新与预览</title><style> | |||
| *{box-sizing:border-box}body{margin:0;background:#f1f7f5;color:#153e36;font:15px/1.65 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1340px;margin:32px auto;padding:0 24px}h1{font-size:28px;margin-bottom:8px}h2{font-size:20px;margin:0 0 12px}p{margin:10px 0}a{color:#07866d}header,article,.note{background:white;border:1px solid #cfe2dd;border-radius:12px;padding:22px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:20px 0}article img{width:100%;max-height:430px;object-fit:contain;background:#f3f8f7}span{font-size:13px;color:#087b61}button{border:1px solid #148872;background:#148872;color:white;border-radius:7px;padding:10px 16px;font:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:wait}article button{width:100%;margin-top:12px}.detail{font-size:12px;word-break:break-all;color:#56796f}.note{border-left:4px solid #be8d36}dialog{width:min(650px,95vw);max-height:95vh;border:1px solid #bdd7cf;border-radius:12px;padding:18px}dialog::backdrop{background:#102722aa}.viewer-head{display:flex;align-items:center;justify-content:space-between;gap:16px}iframe{display:block;width:100%;height:min(65vh,640px);border:0;background:#eaf1ef;margin:12px 0}.viewer-controls{display:flex;gap:14px;align-items:center}.viewer-controls span{flex:1} @media(max-width:800px){.grid{grid-template-columns:1fr}main{margin:16px auto}}</style> | |||
| <main><header><h1>三位教员已更新为 v2 素材</h1><p>2026-09-15 · 已替换共享生成服务、镜像、源码资源包与封面。原有三条形象记录、默认教员2、声音配置保持不变。</p><p><a href="http://127.0.0.1:8003/assets/avatars">打开数字人系统</a> · <a href="catalog-v2.png">查看列表截图</a> · <a href="final-validation.json">核验记录</a> · <a href="ROLLBACK.md">旧版备份位置</a></p></header> | |||
| <section class="grid"><article><h2>教员1 <span>v2</span></h2> | |||
| <img src="stable-1-speaking.png" alt="教员1的实时语音驱动截图"> | |||
| <button onclick="openViewer(1,'a1000000000000000000000000000005')">打开实时形象预览</button> | |||
| <p><a href="file:///E:/work/edit_3d/%E6%95%B0%E5%AD%97%E4%BA%BA%E7%B4%A0%E6%9D%90/%E6%95%99%E5%91%981_v2.mp4">原始 v2 素材</a> · <a href="generated/a1000000000000000000000000000005/assets/01.mp4">新形象底片(无声)</a></p> | |||
| <p class="detail">720 × 960 · 25 FPS · 502 帧<br>原编号:a1000000000000000000000000000005</p></article><article><h2>教员2 <span>v2 · 默认形象</span></h2> | |||
| <img src="stable-2-speaking.png" alt="教员2的实时语音驱动截图"> | |||
| <button onclick="openViewer(2,'a1000000000000000000000000000006')">打开实时形象预览</button> | |||
| <p><a href="file:///E:/work/edit_3d/%E6%95%B0%E5%AD%97%E4%BA%BA%E7%B4%A0%E6%9D%90/%E6%95%99%E5%91%982_v2.mp4">原始 v2 素材</a> · <a href="generated/a1000000000000000000000000000006/assets/01.mp4">新形象底片(无声)</a></p> | |||
| <p class="detail">720 × 960 · 25 FPS · 502 帧<br>原编号:a1000000000000000000000000000006</p></article><article><h2>教员3 <span>v2</span></h2> | |||
| <img src="stable-3-speaking.png" alt="教员3的实时语音驱动截图"> | |||
| <button onclick="openViewer(3,'a1000000000000000000000000000007')">打开实时形象预览</button> | |||
| <p><a href="file:///E:/work/edit_3d/%E6%95%B0%E5%AD%97%E4%BA%BA%E7%B4%A0%E6%9D%90/%E6%95%99%E5%91%983_v2.mp4">原始 v2 素材</a> · <a href="generated/a1000000000000000000000000000007/assets/01.mp4">新形象底片(无声)</a></p> | |||
| <p class="detail">720 × 960 · 25 FPS · 502 帧<br>原编号:a1000000000000000000000000000007</p></article></section><section class="note"><strong>本次核验范围</strong><p>三位原编号均已完成实时加载、语音开始/结束、嘴部驱动与资源哈希核验,未出现运行错误。本次检查不代表“嘴型与每个音节完全吻合”已验收。</p><p>约10秒的原始视频会生成正放/倒放循环底片,时长20.08秒。本报告中的实时形象预览展示无声底片;语音驱动测试的截图及记录已保留。之前生成的讲解 MP4 和 WAV 均保留,没有自动重新生成。</p><p>本次用于驱动验证的讲解音频(单独试听,不联动上方预览):</p><audio controls src="sample.wav"></audio></section></main> | |||
| <dialog id="preview"><div class="viewer-head"><strong id="viewer-title"></strong><button onclick="closeViewer()">关闭</button></div><iframe id="viewer" title="数字人实时预览" allow="autoplay"></iframe><div class="viewer-controls"><span id="status">加载中</span><a href="http://127.0.0.1:8003/assets/avatars" target="_blank">进入数字人系统</a></div></dialog> | |||
| <script> | |||
| const origin='http://127.0.0.1:8003';const frame=document.getElementById('viewer'),dialog=document.getElementById('preview'),status=document.getElementById('status'); | |||
| function openViewer(n,id){status.textContent='正在加载新形象…';document.getElementById('viewer-title').textContent='教员'+n+' · v2 实时预览';dialog.showModal();frame.src=origin+'/human/viewer/'+id;} | |||
| function closeViewer(){frame.src='about:blank';dialog.close();} | |||
| dialog.addEventListener('cancel',e=>{e.preventDefault();closeViewer()}); | |||
| addEventListener('message',e=>{if(e.source!==frame.contentWindow||e.origin!==origin)return;if(e.data?.type==='digital-human.ready')status.textContent='v2 形象已就绪(无声预览)';}); | |||
| </script></html> | |||
| @@ -0,0 +1,38 @@ | |||
| [ | |||
| { | |||
| "number": 1, | |||
| "id": "68b508bb56e049b4bf9320e146b4a9c8", | |||
| "avatar_id": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "status": "ready", | |||
| "stage": "ready", | |||
| "progress": 100, | |||
| "error": "", | |||
| "created_at": "2026-09-14T15:56:38.987426+00:00", | |||
| "updated_at": "2026-09-14T15:57:38.776292+00:00", | |||
| "avatar_url": "/api/v1/avatars/b11e725a21c44b8a8499ee76cfbc8737" | |||
| }, | |||
| { | |||
| "number": 2, | |||
| "id": "9aa04bbf6a26452fb3f63155927eed2c", | |||
| "avatar_id": "73339bc579334a8abf2696d9c4944b87", | |||
| "status": "ready", | |||
| "stage": "ready", | |||
| "progress": 100, | |||
| "error": "", | |||
| "created_at": "2026-09-14T15:56:53.845011+00:00", | |||
| "updated_at": "2026-09-14T15:58:35.782507+00:00", | |||
| "avatar_url": "/api/v1/avatars/73339bc579334a8abf2696d9c4944b87" | |||
| }, | |||
| { | |||
| "number": 3, | |||
| "id": "700077f642a6417c96bb339e8cd32118", | |||
| "avatar_id": "15606bd2b60a4d11951c21907968c3dc", | |||
| "status": "processing", | |||
| "stage": "preparing_video", | |||
| "progress": 5, | |||
| "error": "", | |||
| "created_at": "2026-09-14T15:56:57.886909+00:00", | |||
| "updated_at": "2026-09-14T15:58:35.836594+00:00", | |||
| "avatar_url": "/api/v1/avatars/15606bd2b60a4d11951c21907968c3dc" | |||
| } | |||
| ] | |||
| @@ -0,0 +1,35 @@ | |||
| [ | |||
| { | |||
| "number": 1, | |||
| "stable_id": "a1000000000000000000000000000005", | |||
| "source": "教员1_v2.mp4", | |||
| "source_sha256": "095ad7b86c86500c37e61b223b04e7577c579b8bbc5d5ed02477ad686a08f698", | |||
| "job_id": "68b508bb56e049b4bf9320e146b4a9c8", | |||
| "avatar_id": "b11e725a21c44b8a8499ee76cfbc8737", | |||
| "status": "queued", | |||
| "upload_bytes": 6590546, | |||
| "job_url": "/api/v1/jobs/68b508bb56e049b4bf9320e146b4a9c8" | |||
| }, | |||
| { | |||
| "number": 2, | |||
| "stable_id": "a1000000000000000000000000000006", | |||
| "source": "教员2_v2.mp4", | |||
| "source_sha256": "f5ce0b2edf3998d0702e13c2a64161bc3bb16779112f434bdf23e5d4b2bf03de", | |||
| "job_id": "9aa04bbf6a26452fb3f63155927eed2c", | |||
| "avatar_id": "73339bc579334a8abf2696d9c4944b87", | |||
| "status": "queued", | |||
| "upload_bytes": 6142900, | |||
| "job_url": "/api/v1/jobs/9aa04bbf6a26452fb3f63155927eed2c" | |||
| }, | |||
| { | |||
| "number": 3, | |||
| "stable_id": "a1000000000000000000000000000007", | |||
| "source": "教员3_v2.mp4", | |||
| "source_sha256": "bd848e1d46e0f170ebb441bf856e7f5b93b4229f74852b963a9239cb79ac80d0", | |||
| "job_id": "700077f642a6417c96bb339e8cd32118", | |||
| "avatar_id": "15606bd2b60a4d11951c21907968c3dc", | |||
| "status": "queued", | |||
| "upload_bytes": 1483614, | |||
| "job_url": "/api/v1/jobs/700077f642a6417c96bb339e8cd32118" | |||
| } | |||
| ] | |||
| @@ -0,0 +1,8 @@ | |||
| { | |||
| "backup": "/app/data/backups/instructors-v2-20260914", | |||
| "updated": [ | |||
| "a1000000000000000000000000000005", | |||
| "a1000000000000000000000000000006", | |||
| "a1000000000000000000000000000007" | |||
| ] | |||
| } | |||
| @@ -0,0 +1,4 @@ | |||
| { | |||
| "baseImage": "sha256:2ac13cf158c3a1a50150dfcf7c98c8795a448a5b5353f2a6f2b28fdeacc774a6", | |||
| "remoteRelease": "/home/digital-human-service/releases/instructors-v2-20260914" | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| { | |||
| "backup": "/app/data/backups/instructors-v2-20260914", | |||
| "updated": [ | |||
| "a1000000000000000000000000000005", | |||
| "a1000000000000000000000000000006", | |||
| "a1000000000000000000000000000007" | |||
| ], | |||
| "oldImage": "sha256:2ac13cf158c3a1a50150dfcf7c98c8795a448a5b5353f2a6f2b28fdeacc774a6", | |||
| "newImage": "sha256:213f8a4458db5051dcb81f21ea62c85a26e6e647aa9ae156da8ba38277be4ff7", | |||
| "release": "/home/digital-human-service/releases/instructors-v2-20260914" | |||
| } | |||
| @@ -0,0 +1,104 @@ | |||
| [ | |||
| { | |||
| "number": 1, | |||
| "avatarId": "a1000000000000000000000000000005", | |||
| "driver": { | |||
| "frames": 223, | |||
| "active": 223, | |||
| "maxBlend": 53.52578353881836 | |||
| }, | |||
| "events": [ | |||
| { | |||
| "type": "digital-human.ready", | |||
| "avatarId": "a1000000000000000000000000000005" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-start", | |||
| "avatarId": "a1000000000000000000000000000005", | |||
| "requestId": "v2-validation", | |||
| "duration": 8.256 | |||
| }, | |||
| { | |||
| "type": "digital-human.command-accepted", | |||
| "avatarId": "a1000000000000000000000000000005", | |||
| "requestId": "v2-validation", | |||
| "command": "digital-human.play-audio" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-end", | |||
| "avatarId": "a1000000000000000000000000000005", | |||
| "requestId": "v2-validation", | |||
| "reason": "ended" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| }, | |||
| { | |||
| "number": 2, | |||
| "avatarId": "a1000000000000000000000000000006", | |||
| "driver": { | |||
| "frames": 225, | |||
| "active": 225, | |||
| "maxBlend": 53.5256462097168 | |||
| }, | |||
| "events": [ | |||
| { | |||
| "type": "digital-human.ready", | |||
| "avatarId": "a1000000000000000000000000000006" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-start", | |||
| "avatarId": "a1000000000000000000000000000006", | |||
| "requestId": "v2-validation", | |||
| "duration": 8.256 | |||
| }, | |||
| { | |||
| "type": "digital-human.command-accepted", | |||
| "avatarId": "a1000000000000000000000000000006", | |||
| "requestId": "v2-validation", | |||
| "command": "digital-human.play-audio" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-end", | |||
| "avatarId": "a1000000000000000000000000000006", | |||
| "requestId": "v2-validation", | |||
| "reason": "ended" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| }, | |||
| { | |||
| "number": 3, | |||
| "avatarId": "a1000000000000000000000000000007", | |||
| "driver": { | |||
| "frames": 226, | |||
| "active": 226, | |||
| "maxBlend": 53.52562713623047 | |||
| }, | |||
| "events": [ | |||
| { | |||
| "type": "digital-human.ready", | |||
| "avatarId": "a1000000000000000000000000000007" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-start", | |||
| "avatarId": "a1000000000000000000000000000007", | |||
| "requestId": "v2-validation", | |||
| "duration": 8.256 | |||
| }, | |||
| { | |||
| "type": "digital-human.command-accepted", | |||
| "avatarId": "a1000000000000000000000000000007", | |||
| "requestId": "v2-validation", | |||
| "command": "digital-human.play-audio" | |||
| }, | |||
| { | |||
| "type": "digital-human.speaking-end", | |||
| "avatarId": "a1000000000000000000000000000007", | |||
| "requestId": "v2-validation", | |||
| "reason": "ended" | |||
| } | |||
| ], | |||
| "errors": [] | |||
| } | |||
| ] | |||
| @@ -0,0 +1,91 @@ | |||
| """Verify promoted identities and clean up only this run's temporary avatars.""" | |||
| import hashlib | |||
| import json | |||
| import shlex | |||
| from promote_instructors_v2 import connect, run, RELEASE | |||
| from retrain_instructors_v2 import ROOT, REPORT, IDS, FILES, API, SERVICE, HTTP, api_session, save | |||
| def main(): | |||
| jobs = json.loads((REPORT / 'jobs.json').read_text('utf-8')) | |||
| checks = json.loads((REPORT / 'generated-validation.json').read_text('utf-8')) | |||
| playback = json.loads((REPORT / 'stable-viewer-validation.json').read_text('utf-8')) | |||
| catalog = json.loads((REPORT / 'catalog-validation.json').read_text('utf-8')) | |||
| assert len(playback) == catalog['count'] == 3 | |||
| assert all(not item['errors'] and item['driver']['active'] > 30 for item in playback) | |||
| session = api_session() | |||
| response = session.get(API + '/api/v1/avatars', params={'pageSize': 200}, timeout=30) | |||
| response.raise_for_status() | |||
| after = response.json() | |||
| save('after-platform.json', after) | |||
| before = json.loads((REPORT / 'before-platform.json').read_text('utf-8')) | |||
| # No full catalog sync: it would re-import previously soft-deleted legacy entries. | |||
| assert before['data']['total'] == after['data']['total'] == 3 | |||
| before_items = {item['id']: item for item in before['data']['items']} | |||
| after_items = {item['id']: item for item in after['data']['items']} | |||
| assert set(before_items) == set(after_items) == set(IDS) | |||
| preserved_fields = ('id', 'name', 'status', 'default', 'voice', 'voiceCapabilityId', 'ownerId', 'visibility', 'viewerUrl') | |||
| for ident in IDS: | |||
| assert all(before_items[ident][field] == after_items[ident][field] for field in preserved_fields) | |||
| service_after = [] | |||
| for check in checks: | |||
| ident = check['stable_id'] | |||
| metadata = HTTP.get(SERVICE + '/api/v1/avatars/' + ident, timeout=30) | |||
| metadata.raise_for_status() | |||
| service_after.append(metadata.json()) | |||
| assert metadata.json()['status'] == 'ready' | |||
| for filename in FILES: | |||
| response = HTTP.get(SERVICE + f'/api/v1/avatars/{ident}/assets/{filename}', timeout=45) | |||
| response.raise_for_status() | |||
| assert hashlib.sha256(response.content).hexdigest() == check['sha256'][filename] | |||
| save('after-service.json', service_after) | |||
| client = connect() | |||
| try: | |||
| expected = {row['stable_id']: row['sha256'] for row in checks} | |||
| code = 'from pathlib import Path;import json,hashlib;expected=' + repr(expected) + ";print(json.dumps({ident:{name:hashlib.sha256((Path('/app/builtin_avatars')/ident/'assets'/name).read_bytes()).hexdigest() for name in files} for ident,files in expected.items()}))" | |||
| actual = json.loads(run(client, 'docker run --rm --network none --entrypoint python digital-human-service:instructors-v2-20260914 -c ' + shlex.quote(code))) | |||
| assert actual == expected | |||
| save('image-validation.json', actual) | |||
| # The list uses source-delivered PNG covers, so replace those exact assets too. | |||
| run(client, f'mkdir -p {RELEASE}/backup-covers/source {RELEASE}/backup-covers/published') | |||
| with client.open_sftp() as sftp: | |||
| for number in (1, 2, 3): | |||
| name = f'equipment-instructor-{number}.png' | |||
| local = ROOT / 'ai_person/ai_person_web/public/avatars/builtin' / name | |||
| for group, base in ( | |||
| ('source', '/home/ai_person/ai_person_web/public/avatars/builtin'), | |||
| ('published', '/home/ai_person/html_ap/avatars/builtin'), | |||
| ): | |||
| target = base + '/' + name | |||
| backup = RELEASE + '/backup-covers/' + group + '/' + name | |||
| run(client, f'test -f {target} && test ! -e {backup} && cp {target} {backup}') | |||
| sftp.put(str(local), target + '.v2-stage') | |||
| sftp.posix_rename(target + '.v2-stage', target) | |||
| with sftp.open(target, 'rb') as stream: | |||
| assert hashlib.sha256(stream.read()).digest() == hashlib.sha256(local.read_bytes()).digest() | |||
| # Keep service-side generation logs in the rollback/audit report before deleting temporary jobs. | |||
| with client.open_sftp() as sftp: | |||
| for job in jobs: | |||
| sftp.get('/home/digital-human-service/data/jobs/' + job['job_id'] + '.log', str(REPORT / f"generation-{job['number']}.log")) | |||
| finally: | |||
| client.close() | |||
| removed = [] | |||
| for job in jobs: | |||
| assert job['avatar_id'] not in IDS | |||
| response = HTTP.delete(SERVICE + '/api/v1/avatars/' + job['avatar_id'], timeout=45) | |||
| assert response.status_code == 204, response.status_code | |||
| removed.append(job['avatar_id']) | |||
| save('final-validation.json', { | |||
| 'identitiesPreserved': True, 'defaultInstructor': 2, 'voicesPreserved': True, | |||
| 'platformCount': 3, 'runtimeAssetHashesMatch': True, 'imageAssetHashesMatch': True, | |||
| 'localAndPublishedCoversUpdated': True, 'temporaryAvatarsRemoved': removed, | |||
| 'oldLectureVideosAndAudioPreserved': True, | |||
| 'scope': 'Resource regeneration and runtime playback verified; phoneme-level lip sync quality not accepted by this check.', | |||
| }) | |||
| print('Verified: three original identities, default instructor, voices, runtime/image assets and covers. Temporary generation entries removed.') | |||
| if __name__ == '__main__': | |||
| main() | |||
| @@ -0,0 +1,124 @@ | |||
| """Promote the three validated v2 bundles without replacing platform identities.""" | |||
| import importlib.util | |||
| import json | |||
| import shlex | |||
| import shutil | |||
| import subprocess | |||
| from pathlib import Path | |||
| import paramiko | |||
| from retrain_instructors_v2 import ROOT, REPORT, BUNDLES, IDS, FILES, api_session, API, HTTP, SERVICE, save | |||
| RELEASE = '/home/digital-human-service/releases/instructors-v2-20260914' | |||
| HOST_SOURCE = '/home/ai_person/ai_person_service/builtin_avatars' | |||
| CONTAINER = 'digital-human-service' | |||
| def connect(): | |||
| spec = importlib.util.spec_from_file_location('connection', ROOT / 'ai_person/ai_person_api/.run/test_file_service_tunnel.py') | |||
| module = importlib.util.module_from_spec(spec) | |||
| spec.loader.exec_module(module) | |||
| host, port, user, password = module._read_connection() | |||
| client = paramiko.SSHClient() | |||
| client.load_system_host_keys() | |||
| client.set_missing_host_key_policy(paramiko.RejectPolicy()) | |||
| client.connect(host, port=port, username=user, password=password, look_for_keys=False, allow_agent=False) | |||
| return client | |||
| def run(client, command): | |||
| _, stdout, stderr = client.exec_command(command, timeout=300) | |||
| output, error = stdout.read().decode('utf-8', 'replace'), stderr.read().decode('utf-8', 'replace') | |||
| if stdout.channel.recv_exit_status() != 0: | |||
| raise RuntimeError(output + error) | |||
| return output.strip() | |||
| def remote_promote(client, jobs, checks): | |||
| # Use the service's own metadata operation. No SQL is issued against the platform database. | |||
| script = r''' | |||
| import hashlib,json,shutil | |||
| from pathlib import Path | |||
| from app.config import settings | |||
| from app import database | |||
| rows=PAYLOAD | |||
| backup=settings.data_root/'backups'/'instructors-v2-20260914' | |||
| if backup.exists():raise RuntimeError('Backup exists; refuse a second promotion') | |||
| source=settings.project_root/'builtin_avatars' | |||
| catalog={x['id']:x for x in json.loads((source/'catalog.json').read_text())} | |||
| for row in rows: | |||
| assert row['stable_id'] in catalog | |||
| assets=settings.avatars_root/row['avatar_id']/'assets' | |||
| assert database.get_avatar(row['avatar_id'])['status']=='ready' | |||
| for name,digest in row['sha256'].items():assert hashlib.sha256((assets/name).read_bytes()).hexdigest()==digest | |||
| backup.mkdir(parents=True) | |||
| before=[] | |||
| for row in rows: | |||
| ident=row['stable_id'] | |||
| before.append(database.get_avatar(ident)) | |||
| shutil.copytree(source/ident,backup/'builtin'/ident) | |||
| shutil.copytree(settings.avatars_root/ident,backup/'runtime'/ident) | |||
| (backup/'before-metadata.json').write_text(json.dumps(before,ensure_ascii=False,indent=2)) | |||
| for row in rows: | |||
| ident=row['stable_id'];assets=settings.avatars_root/row['avatar_id']/'assets' | |||
| for root in (source,settings.avatars_root): | |||
| stage=root/(ident+'.v2-stage') | |||
| if stage.exists():raise RuntimeError('Unexpected stale staging directory') | |||
| shutil.copytree(root/ident,stage) | |||
| for name in row['sha256']:shutil.copy2(assets/name,stage/'assets'/name) | |||
| previous=root/(ident+'.v1-previous') | |||
| if previous.exists():raise RuntimeError('Unexpected previous directory') | |||
| (root/ident).rename(previous) | |||
| try:stage.rename(root/ident) | |||
| except BaseException:previous.rename(root/ident);raise | |||
| # Keep the renamed directories until all replacement checks finish. | |||
| manifest=json.loads((assets/'manifest.json').read_text()) | |||
| spec=catalog[ident] | |||
| database.upsert_builtin_avatar(avatar_id=ident,name=spec['name'],source_filename='built_in:'+spec['code'], | |||
| frame_count=manifest['frame_count'],width=manifest['width'],height=manifest['height'],duration=manifest['duration']) | |||
| print(json.dumps({'backup':str(backup),'updated':[row['stable_id'] for row in rows]})) | |||
| '''.replace('PAYLOAD', repr([{**job, 'sha256': check['sha256']} for job, check in zip(jobs, checks)])) | |||
| return run(client, 'docker exec -w /app ' + CONTAINER + ' python -c ' + shlex.quote(script)) | |||
| def main(): | |||
| if (REPORT / 'promotion.json').exists(): | |||
| raise RuntimeError('Already promoted; do not run twice') | |||
| jobs = json.loads((REPORT / 'jobs.json').read_text('utf-8')) | |||
| checks = json.loads((REPORT / 'generated-validation.json').read_text('utf-8')) | |||
| playback = json.loads((REPORT / 'generated-viewer-validation.json').read_text('utf-8')) | |||
| assert len(jobs) == len(checks) == len(playback) == 3 | |||
| assert all(not row['errors'] and row['driver']['active'] > 30 for row in playback) | |||
| client = connect() | |||
| try: | |||
| base_image = run(client, "docker inspect digital-human-service --format '{{.Image}}'") | |||
| run(client, f'mkdir -p {RELEASE}/backup-host {RELEASE}/builtin_avatars') | |||
| for ident in IDS: | |||
| run(client, f'test -d {HOST_SOURCE}/{ident} && cp -a {HOST_SOURCE}/{ident} {RELEASE}/backup-host/{ident}') | |||
| save('promotion-start.json', {'baseImage': base_image, 'remoteRelease': RELEASE}) | |||
| result = json.loads(remote_promote(client, jobs, checks)) | |||
| save('promotion-runtime.json', result) | |||
| for ident in IDS: | |||
| run(client, f'docker cp {CONTAINER}:/app/builtin_avatars/{ident} {RELEASE}/builtin_avatars/{ident}') | |||
| for filename in FILES: | |||
| run(client, f'cp {RELEASE}/builtin_avatars/{ident}/assets/{filename} {HOST_SOURCE}/{ident}/assets/{filename}') | |||
| shutil.copy2(REPORT / 'generated' / ident / 'assets' / filename, BUNDLES / ident / 'assets' / filename) | |||
| # Persist bundles in the deployment image as well as the running container. | |||
| # The base is the exact current image; no application source is upgraded. | |||
| dockerfile = f'FROM {base_image}\nCOPY builtin_avatars/ /app/builtin_avatars/\nLABEL ai-person.avatar-material-version="instructors-v2-20260914"\n' | |||
| with client.open_sftp() as sftp: | |||
| with sftp.open(RELEASE + '/Dockerfile', 'w') as stream: | |||
| stream.write(dockerfile) | |||
| run(client, f'docker tag {base_image} digital-human-service:before-instructors-v2-20260914') | |||
| output = run(client, f'docker build --network=none -t digital-human-service:instructors-v2-20260914 {RELEASE}') | |||
| (REPORT / 'image-build.log').write_text(output, encoding='utf-8') | |||
| run(client, 'docker tag digital-human-service:instructors-v2-20260914 digital-human-service:cpu') | |||
| image_id = run(client, "docker image inspect digital-human-service:cpu --format '{{.Id}}'") | |||
| save('promotion.json', {**result, 'oldImage': base_image, 'newImage': image_id, 'release': RELEASE}) | |||
| print(json.dumps({'updated': IDS, 'newImage': image_id, 'backup': result['backup']}), flush=True) | |||
| finally: | |||
| client.close() | |||
| if __name__ == '__main__': | |||
| main() | |||
| @@ -0,0 +1,30 @@ | |||
| import json | |||
| from retrain_instructors_v2 import ROOT, REPORT | |||
| jobs = json.loads((REPORT / 'jobs.json').read_text('utf-8')) | |||
| audio = (ROOT / 'unreal_tran/ute2e/reports/bridge-lipsync-diagnosis-20260914/sample.wav').read_bytes() | |||
| (REPORT / 'sample.wav').write_bytes(audio) | |||
| cards = [] | |||
| for row in jobs: | |||
| n, ident = row['number'], row['stable_id'] | |||
| source = (ROOT / '数字人素材' / row['source']).as_uri() | |||
| cards.append(f'''<article><h2>教员{n} <span>v2{' · 默认形象' if n == 2 else ''}</span></h2> | |||
| <img src="stable-{n}-speaking.png" alt="教员{n}的实时语音驱动截图"> | |||
| <button onclick="openViewer({n},'{ident}')">打开实时形象预览</button> | |||
| <p><a href="{source}">原始 v2 素材</a> · <a href="generated/{ident}/assets/01.mp4">新形象底片(无声)</a></p> | |||
| <p class="detail">720 × 960 · 25 FPS · 502 帧<br>原编号:{ident}</p></article>''') | |||
| html = '''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |||
| <title>三位教员 v2 资源更新与预览</title><style> | |||
| *{box-sizing:border-box}body{margin:0;background:#f1f7f5;color:#153e36;font:15px/1.65 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1340px;margin:32px auto;padding:0 24px}h1{font-size:28px;margin-bottom:8px}h2{font-size:20px;margin:0 0 12px}p{margin:10px 0}a{color:#07866d}header,article,.note{background:white;border:1px solid #cfe2dd;border-radius:12px;padding:22px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:20px 0}article img{width:100%;max-height:430px;object-fit:contain;background:#f3f8f7}span{font-size:13px;color:#087b61}button{border:1px solid #148872;background:#148872;color:white;border-radius:7px;padding:10px 16px;font:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:wait}article button{width:100%;margin-top:12px}.detail{font-size:12px;word-break:break-all;color:#56796f}.note{border-left:4px solid #be8d36}dialog{width:min(650px,95vw);max-height:95vh;border:1px solid #bdd7cf;border-radius:12px;padding:18px}dialog::backdrop{background:#102722aa}.viewer-head{display:flex;align-items:center;justify-content:space-between;gap:16px}iframe{display:block;width:100%;height:min(65vh,640px);border:0;background:#eaf1ef;margin:12px 0}.viewer-controls{display:flex;gap:14px;align-items:center}.viewer-controls span{flex:1} @media(max-width:800px){.grid{grid-template-columns:1fr}main{margin:16px auto}}</style> | |||
| <main><header><h1>三位教员已更新为 v2 素材</h1><p>2026-09-15 · 已替换共享生成服务、镜像、源码资源包与封面。原有三条形象记录、默认教员2、声音配置保持不变。</p><p><a href="http://127.0.0.1:8003/assets/avatars">打开数字人系统</a> · <a href="catalog-v2.png">查看列表截图</a> · <a href="final-validation.json">核验记录</a> · <a href="ROLLBACK.md">旧版备份位置</a></p></header> | |||
| <section class="grid">CARDS</section><section class="note"><strong>本次核验范围</strong><p>三位原编号均已完成实时加载、语音开始/结束、嘴部驱动与资源哈希核验,未出现运行错误。本次检查不代表“嘴型与每个音节完全吻合”已验收。</p><p>约10秒的原始视频会生成正放/倒放循环底片,时长20.08秒。本报告中的实时形象预览展示无声底片;语音驱动测试的截图及记录已保留。之前生成的讲解 MP4 和 WAV 均保留,没有自动重新生成。</p><p>本次用于驱动验证的讲解音频(单独试听,不联动上方预览):</p><audio controls src="sample.wav"></audio></section></main> | |||
| <dialog id="preview"><div class="viewer-head"><strong id="viewer-title"></strong><button onclick="closeViewer()">关闭</button></div><iframe id="viewer" title="数字人实时预览" allow="autoplay"></iframe><div class="viewer-controls"><span id="status">加载中</span><a href="http://127.0.0.1:8003/assets/avatars" target="_blank">进入数字人系统</a></div></dialog> | |||
| <script> | |||
| const origin='http://127.0.0.1:8003';const frame=document.getElementById('viewer'),dialog=document.getElementById('preview'),status=document.getElementById('status'); | |||
| function openViewer(n,id){status.textContent='正在加载新形象…';document.getElementById('viewer-title').textContent='教员'+n+' · v2 实时预览';dialog.showModal();frame.src=origin+'/human/viewer/'+id;} | |||
| function closeViewer(){frame.src='about:blank';dialog.close();} | |||
| dialog.addEventListener('cancel',e=>{e.preventDefault();closeViewer()}); | |||
| addEventListener('message',e=>{if(e.source!==frame.contentWindow||e.origin!==origin)return;if(e.data?.type==='digital-human.ready')status.textContent='v2 形象已就绪(无声预览)';}); | |||
| </script></html>'''.replace('CARDS', ''.join(cards)) | |||
| (REPORT / 'index.html').write_text(html, encoding='utf-8') | |||
| print('Report written:', REPORT / 'index.html') | |||
| @@ -0,0 +1,121 @@ | |||
| """Reproducible v2 instructor generation; reports contain no credentials.""" | |||
| from pathlib import Path | |||
| import argparse | |||
| import gzip | |||
| import hashlib | |||
| import json | |||
| import re | |||
| import shutil | |||
| import requests | |||
| ROOT = Path(__file__).resolve().parents[3] | |||
| REPORT = ROOT / 'ai_person/ape2e/reports/instructors-v2-20260914' | |||
| BUNDLES = ROOT / 'ai_person/ai_person_service/builtin_avatars' | |||
| SERVICE = 'http://1.14.103.234:8123' | |||
| API = 'http://127.0.0.1:8001' | |||
| FILES = ('01.mp4', 'combined_data.json.gz', 'thumbnail.jpg', 'manifest.json') | |||
| IDS = [f'a100000000000000000000000000000{i}' for i in (5, 6, 7)] | |||
| HTTP = requests.Session() | |||
| HTTP.trust_env = False | |||
| def save(name, value): | |||
| REPORT.mkdir(parents=True, exist_ok=True) | |||
| (REPORT / name).write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding='utf-8') | |||
| def api_session(): | |||
| values = {} | |||
| for line in (ROOT / 'ai_person/账号.MD').read_text(encoding='utf-8-sig').splitlines(): | |||
| match = re.match(r'^\s*([^::]+)\s*[::]\s*(.*?)\s*$', line) | |||
| if match: | |||
| values[match[1].strip()] = match[2].strip() | |||
| session = requests.Session() | |||
| session.trust_env = False | |||
| response = session.post(API + '/api/auth/v1/auth/login', json={ | |||
| 'roleCode': 'admin', 'username': values.get('账号') or values.get('用户名'), | |||
| 'password': values['密码'], 'rememberMe': False, | |||
| }, timeout=30) | |||
| response.raise_for_status() | |||
| data = response.json()['data'] | |||
| token = data.get('accessToken') or data['tokens']['accessToken'] | |||
| session.headers['Authorization'] = 'Bearer ' + token | |||
| return session | |||
| def get_json(url): | |||
| response = HTTP.get(url, timeout=30) | |||
| response.raise_for_status() | |||
| return response.json() | |||
| def snapshot(): | |||
| if (REPORT / 'before-platform.json').exists(): | |||
| raise RuntimeError('Backup already exists; refusing to overwrite') | |||
| session = api_session() | |||
| response = session.get(API + '/api/v1/avatars', params={'pageSize': 200}, timeout=30) | |||
| response.raise_for_status() | |||
| save('before-platform.json', response.json()) | |||
| save('before-service.json', [get_json(SERVICE + '/api/v1/avatars/' + ident) for ident in IDS]) | |||
| for ident in IDS: | |||
| shutil.copytree(BUNDLES / ident, REPORT / 'backup-local' / ident) | |||
| print('Backed up platform catalog, service metadata and all three local bundles') | |||
| def submit(): | |||
| if not (REPORT / 'before-platform.json').exists(): | |||
| raise RuntimeError('Run snapshot first') | |||
| state = json.loads((REPORT / 'jobs.json').read_text(encoding='utf-8')) if (REPORT / 'jobs.json').exists() else [] | |||
| for number, ident in enumerate(IDS, 1): | |||
| if any(item['stable_id'] == ident for item in state): | |||
| continue | |||
| source = ROOT / '数字人素材' / f'教员{number}_v2.mp4' | |||
| with source.open('rb') as stream: | |||
| response = HTTP.post(SERVICE + '/api/v1/avatars', | |||
| files={'video': (source.name, stream, 'video/mp4')}, | |||
| data={'name': f'教员{number} v2 重训 20260914', 'resize': 'true', 'matting': 'off'}, timeout=180) | |||
| response.raise_for_status() | |||
| item = {'number': number, 'stable_id': ident, 'source': source.name, | |||
| 'source_sha256': hashlib.sha256(source.read_bytes()).hexdigest(), **response.json()} | |||
| state.append(item) | |||
| save('jobs.json', state) | |||
| print(json.dumps(item, ensure_ascii=False), flush=True) | |||
| def status(): | |||
| values = [] | |||
| for item in json.loads((REPORT / 'jobs.json').read_text(encoding='utf-8')): | |||
| job = get_json(SERVICE + item['job_url']) | |||
| values.append({'number': item['number'], **job}) | |||
| save('job-status.json', values) | |||
| print(json.dumps(values, ensure_ascii=False, indent=2)) | |||
| def download(): | |||
| values = [] | |||
| for item in json.loads((REPORT / 'jobs.json').read_text(encoding='utf-8')): | |||
| avatar = get_json(SERVICE + '/api/v1/avatars/' + item['avatar_id']) | |||
| if avatar['status'] != 'ready': | |||
| raise RuntimeError(f"Instructor {item['number']} not ready") | |||
| target = REPORT / 'generated' / item['stable_id'] / 'assets' | |||
| target.mkdir(parents=True, exist_ok=True) | |||
| for filename in FILES: | |||
| response = HTTP.get(SERVICE + f"/api/v1/avatars/{item['avatar_id']}/assets/{filename}", timeout=120) | |||
| response.raise_for_status() | |||
| (target / filename).write_bytes(response.content) | |||
| manifest = json.loads((target / 'manifest.json').read_text(encoding='utf-8')) | |||
| for filename, length in manifest['files'].items(): | |||
| assert (target / filename).stat().st_size == length, filename | |||
| with gzip.open(target / 'combined_data.json.gz', 'rt', encoding='utf-8') as stream: | |||
| geometry = json.load(stream) | |||
| values.append({'number': item['number'], 'stable_id': item['stable_id'], 'generated_id': item['avatar_id'], | |||
| 'manifest': manifest, 'geometry_type': type(geometry).__name__, | |||
| 'sha256': {name: hashlib.sha256((target / name).read_bytes()).hexdigest() for name in FILES}}) | |||
| save('generated-validation.json', values) | |||
| print(json.dumps(values, ensure_ascii=False, indent=2)) | |||
| if __name__ == '__main__': | |||
| parser = argparse.ArgumentParser() | |||
| parser.add_argument('phase', choices=['snapshot', 'submit', 'status', 'download']) | |||
| globals()[parser.parse_args().phase]() | |||
| @@ -0,0 +1,57 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import { chromium } from '@playwright/test'; | |||
| const root = path.resolve('../..'); | |||
| const sourceReport = path.resolve('reports/instructors-v2-20260914'); | |||
| const report = path.resolve(process.env.REPORT_DIR || sourceReport); | |||
| fs.mkdirSync(report,{recursive:true}); | |||
| const stable = process.argv.includes('--stable'); | |||
| const jobs = JSON.parse(fs.readFileSync(`${sourceReport}/jobs.json`, 'utf8')); | |||
| const origin = process.env.BASE_URL || 'http://127.0.0.1:8003'; | |||
| const audio = fs.readFileSync(`${root}/unreal_tran/ute2e/reports/bridge-lipsync-diagnosis-20260914/sample.wav`).toString('base64'); | |||
| const browser = await chromium.launch({ channel: 'msedge', headless: true, | |||
| args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); | |||
| const results = []; | |||
| try { | |||
| for (const job of jobs) { | |||
| const id = stable ? job.stable_id : job.avatar_id; | |||
| const context = await browser.newContext({ viewport: { width: 560, height: 760 } }); | |||
| await context.route(`${origin}/__instructor-v2-check`, route => route.fulfill({ contentType: 'text/html', body: | |||
| `<html><meta charset="utf-8"><body style="margin:16px;background:#f3f8f7;font:16px sans-serif"><h3>教员${job.number} · v2 语音驱动预览</h3><iframe style="border:0;width:480px;height:640px" src="/human/viewer/${id}" allow="autoplay"></iframe><script>window.events=[];addEventListener('message',e=>{if(e.origin===location.origin&&e.data?.type?.startsWith('digital-human.'))window.events.push(e.data)});</script></body></html>` })); | |||
| const page = await context.newPage(); | |||
| const errors = []; | |||
| page.on('pageerror', error => errors.push(error.message)); | |||
| await page.goto(`${origin}/__instructor-v2-check`); | |||
| const frame = page.frames().find(frame => frame.url().includes('/human/viewer/')); | |||
| await frame.waitForFunction(() => window.DH_RUNTIME_READY, {}, { timeout: 120000 }); | |||
| await frame.evaluate(() => { | |||
| window.v2Check = { frames: 0, active: 0, maxBlend: 0 }; | |||
| const original = Module._updateBlendShape; | |||
| Module._updateBlendShape = function(pointer, count) { | |||
| const result = original(pointer, count); | |||
| const max = Math.max(...Array.from(new Float32Array(Module.HEAPU8.buffer, pointer, 12)).map(Math.abs)); | |||
| window.v2Check.frames++; | |||
| window.v2Check.maxBlend = Math.max(window.v2Check.maxBlend, max); | |||
| if (max > .001) window.v2Check.active++; | |||
| return result; | |||
| }; | |||
| }); | |||
| await page.screenshot({ path: `${report}/${stable ? 'stable' : 'generated'}-${job.number}-idle.png` }); | |||
| await page.evaluate(audioBase64 => document.querySelector('iframe').contentWindow.postMessage({ | |||
| type: 'digital-human.play-audio', requestId: 'v2-validation', audioBase64, | |||
| }, location.origin), audio); | |||
| await page.waitForFunction(() => window.events.some(event => event.type === 'digital-human.speaking-start'), {}, { timeout: 15000 }); | |||
| await frame.waitForFunction(() => window.v2Check.active > 30, {}, { timeout: 15000 }); | |||
| await page.screenshot({ path: `${report}/${stable ? 'stable' : 'generated'}-${job.number}-speaking.png` }); | |||
| await page.waitForFunction(() => window.events.some(event => event.type === 'digital-human.speaking-end'), {}, { timeout: 20000 }); | |||
| const events = await page.evaluate(() => window.events); | |||
| const driver = await frame.evaluate(() => window.v2Check); | |||
| if (errors.length || events.some(event => event.type === 'digital-human.error')) throw new Error(JSON.stringify({ errors, events })); | |||
| const result = { number: job.number, avatarId: id, driver, events, errors }; | |||
| results.push(result); | |||
| console.log(JSON.stringify(result)); | |||
| fs.writeFileSync(`${report}/${stable ? 'stable' : 'generated'}-viewer-validation.json`, JSON.stringify(results, null, 2)); | |||
| await context.close(); | |||
| } | |||
| } finally { await browser.close(); } | |||
| @@ -0,0 +1,53 @@ | |||
| import fs from 'node:fs'; | |||
| import path from 'node:path'; | |||
| import assert from 'node:assert/strict'; | |||
| import { chromium } from '@playwright/test'; | |||
| const report = path.resolve('reports/instructors-v2-20260914'); | |||
| const origin = 'http://127.0.0.1:8003'; | |||
| const values = new Map(fs.readFileSync('../账号.MD', 'utf8').split(/\r?\n/).flatMap(line => { | |||
| const match = line.match(/^\s*([^::]+)\s*[::]\s*(.*?)\s*$/); | |||
| return match ? [[match[1].trim(), match[2].trim()]] : []; | |||
| })); | |||
| const browser = await chromium.launch({ channel: 'msedge', headless: true, | |||
| args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); | |||
| try { | |||
| const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } }); | |||
| const page = await context.newPage(); | |||
| const errors = []; | |||
| page.on('pageerror', error => errors.push(error.message)); | |||
| await page.goto(origin + '/login'); | |||
| await page.locator('[data-role-code="admin"]').click(); | |||
| await page.locator('input[name="username"]').fill(values.get('账号') || values.get('用户名')); | |||
| await page.locator('input[name="password"]').fill(values.get('密码')); | |||
| await page.getByRole('button', { name: /^(?:登录|登录系统|正在验证身份)$/ }).click(); | |||
| await page.waitForURL(url => !url.pathname.startsWith('/login'), { timeout: 30000 }); | |||
| await page.goto(origin + '/assets/avatars'); | |||
| await page.locator('.avatar-card').first().waitFor(); | |||
| assert.equal(await page.locator('.avatar-card').count(), 3); | |||
| await page.waitForFunction(() => [...document.querySelectorAll('.avatar-cover img')].every(image => image.complete && image.naturalWidth > 0)); | |||
| await page.screenshot({ path: report + '/catalog-v2.png', fullPage: true }); | |||
| const cards = await page.locator('.avatar-card').allTextContents(); | |||
| assert.ok(cards.find(text => text.includes('教员2'))?.includes('默认数字人')); | |||
| const previews = []; | |||
| for (const number of [1, 2, 3]) { | |||
| const card = page.locator('.avatar-card').filter({ has: page.getByRole('heading', { name: `教员${number}`, exact: true }) }); | |||
| await card.getByRole('button', { name: '预览', exact: true }).click(); | |||
| const dialog = page.locator('.el-dialog:visible'); | |||
| await dialog.waitFor(); | |||
| const iframe = dialog.locator('iframe'); | |||
| await iframe.waitFor(); | |||
| const source = await iframe.getAttribute('src'); | |||
| assert.ok(source.includes(`a100000000000000000000000000000${number + 4}`)); | |||
| const frame = await (await iframe.elementHandle()).contentFrame(); | |||
| await frame.waitForFunction(() => window.DH_RUNTIME_READY, {}, { timeout: 120000 }); | |||
| await page.screenshot({ path: `${report}/catalog-preview-${number}.png`, fullPage: true }); | |||
| previews.push({ number, viewer: source, ready: true }); | |||
| await dialog.locator('.el-dialog__headerbtn').click(); | |||
| await dialog.waitFor({ state: 'hidden' }); | |||
| } | |||
| assert.deepEqual(errors, []); | |||
| const result = { count: 3, defaultInstructor: 2, previews, errors }; | |||
| fs.writeFileSync(`${report}/catalog-validation.json`, JSON.stringify(result, null, 2)); | |||
| console.log(JSON.stringify(result)); | |||
| } finally { await browser.close(); } | |||