| @@ -4,3 +4,5 @@ artifacts/runs/ | |||||
| artifacts/deploy-smoke/ | artifacts/deploy-smoke/ | ||||
| playwright-report/ | playwright-report/ | ||||
| test-results/ | 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(); } | |||||