Просмотр исходного кода

test(avatars): preserve v2 retraining and preview verification

main
leiyun 4 дней назад
Родитель
Сommit
c0f08b5716
72 измененных файлов: 1456 добавлений и 0 удалений
  1. +2
    -0
      .gitignore
  2. +17
    -0
      reports/instructors-v2-20260914/ROLLBACK.md
  3. +128
    -0
      reports/instructors-v2-20260914/after-platform.json
  4. +59
    -0
      reports/instructors-v2-20260914/after-service.json
  5. Двоичные данные
     
  6. Двоичные данные
     
  7. Двоичные данные
     
  8. Двоичные данные
     
  9. Двоичные данные
     
  10. +18
    -0
      reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000005/assets/manifest.json
  11. Двоичные данные
     
  12. Двоичные данные
     
  13. Двоичные данные
     
  14. +18
    -0
      reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000006/assets/manifest.json
  15. Двоичные данные
     
  16. Двоичные данные
     
  17. Двоичные данные
     
  18. +18
    -0
      reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000007/assets/manifest.json
  19. Двоичные данные
     
  20. +128
    -0
      reports/instructors-v2-20260914/before-platform.json
  21. +59
    -0
      reports/instructors-v2-20260914/before-service.json
  22. Двоичные данные
     
  23. Двоичные данные
     
  24. Двоичные данные
     
  25. Двоичные данные
     
  26. +22
    -0
      reports/instructors-v2-20260914/catalog-validation.json
  27. +16
    -0
      reports/instructors-v2-20260914/final-validation.json
  28. Двоичные данные
     
  29. Двоичные данные
     
  30. Двоичные данные
     
  31. Двоичные данные
     
  32. Двоичные данные
     
  33. Двоичные данные
     
  34. +92
    -0
      reports/instructors-v2-20260914/generated-validation.json
  35. +104
    -0
      reports/instructors-v2-20260914/generated-viewer-validation.json
  36. Двоичные данные
     
  37. Двоичные данные
     
  38. +18
    -0
      reports/instructors-v2-20260914/generated/a1000000000000000000000000000005/assets/manifest.json
  39. Двоичные данные
     
  40. Двоичные данные
     
  41. Двоичные данные
     
  42. +18
    -0
      reports/instructors-v2-20260914/generated/a1000000000000000000000000000006/assets/manifest.json
  43. Двоичные данные
     
  44. Двоичные данные
     
  45. Двоичные данные
     
  46. +18
    -0
      reports/instructors-v2-20260914/generated/a1000000000000000000000000000007/assets/manifest.json
  47. Двоичные данные
     
  48. +20
    -0
      reports/instructors-v2-20260914/image-validation.json
  49. +25
    -0
      reports/instructors-v2-20260914/index.html
  50. Двоичные данные
     
  51. Двоичные данные
     
  52. Двоичные данные
     
  53. +38
    -0
      reports/instructors-v2-20260914/job-status.json
  54. +35
    -0
      reports/instructors-v2-20260914/jobs.json
  55. +8
    -0
      reports/instructors-v2-20260914/promotion-runtime.json
  56. +4
    -0
      reports/instructors-v2-20260914/promotion-start.json
  57. +11
    -0
      reports/instructors-v2-20260914/promotion.json
  58. Двоичные данные
     
  59. Двоичные данные
     
  60. Двоичные данные
     
  61. Двоичные данные
     
  62. Двоичные данные
     
  63. Двоичные данные
     
  64. Двоичные данные
     
  65. Двоичные данные
     
  66. +104
    -0
      reports/instructors-v2-20260914/stable-viewer-validation.json
  67. +91
    -0
      tools/finalize_instructors_v2.py
  68. +124
    -0
      tools/promote_instructors_v2.py
  69. +30
    -0
      tools/report_instructors_v2.py
  70. +121
    -0
      tools/retrain_instructors_v2.py
  71. +57
    -0
      tools/verify_instructors_v2.mjs
  72. +53
    -0
      tools/verify_instructors_v2_catalog.mjs

+ 2
- 0
.gitignore Просмотреть файл

@@ -4,3 +4,5 @@ artifacts/runs/
artifacts/deploy-smoke/
playwright-report/
test-results/
reports/**/*.log
**/__pycache__/

+ 17
- 0
reports/instructors-v2-20260914/ROLLBACK.md Просмотреть файл

@@ -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没有改动。

+ 128
- 0
reports/instructors-v2-20260914/after-platform.json Просмотреть файл

@@ -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"
}

+ 59
- 0
reports/instructors-v2-20260914/after-service.json Просмотреть файл

@@ -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"
}
]

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 18
- 0
reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000005/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
}
}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 18
- 0
reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000006/assets/manifest.json Просмотреть файл

@@ -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
}
}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 18
- 0
reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000007/assets/manifest.json Просмотреть файл

@@ -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
}
}

Двоичные данные
Просмотреть файл


+ 128
- 0
reports/instructors-v2-20260914/before-platform.json Просмотреть файл

@@ -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"
}

+ 59
- 0
reports/instructors-v2-20260914/before-service.json Просмотреть файл

@@ -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"
}
]

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 22
- 0
reports/instructors-v2-20260914/catalog-validation.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": []
}

+ 16
- 0
reports/instructors-v2-20260914/final-validation.json Просмотреть файл

@@ -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."
}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 92
- 0
reports/instructors-v2-20260914/generated-validation.json Просмотреть файл

@@ -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"
}
}
]

+ 104
- 0
reports/instructors-v2-20260914/generated-viewer-validation.json Просмотреть файл

@@ -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": []
}
]

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 18
- 0
reports/instructors-v2-20260914/generated/a1000000000000000000000000000005/assets/manifest.json Просмотреть файл

@@ -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
}
}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 18
- 0
reports/instructors-v2-20260914/generated/a1000000000000000000000000000006/assets/manifest.json Просмотреть файл

@@ -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
}
}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 18
- 0
reports/instructors-v2-20260914/generated/a1000000000000000000000000000007/assets/manifest.json Просмотреть файл

@@ -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
}
}

Двоичные данные
Просмотреть файл


+ 20
- 0
reports/instructors-v2-20260914/image-validation.json Просмотреть файл

@@ -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"
}
}

+ 25
- 0
reports/instructors-v2-20260914/index.html Просмотреть файл

@@ -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>

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 38
- 0
reports/instructors-v2-20260914/job-status.json Просмотреть файл

@@ -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"
}
]

+ 35
- 0
reports/instructors-v2-20260914/jobs.json Просмотреть файл

@@ -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"
}
]

+ 8
- 0
reports/instructors-v2-20260914/promotion-runtime.json Просмотреть файл

@@ -0,0 +1,8 @@
{
"backup": "/app/data/backups/instructors-v2-20260914",
"updated": [
"a1000000000000000000000000000005",
"a1000000000000000000000000000006",
"a1000000000000000000000000000007"
]
}

+ 4
- 0
reports/instructors-v2-20260914/promotion-start.json Просмотреть файл

@@ -0,0 +1,4 @@
{
"baseImage": "sha256:2ac13cf158c3a1a50150dfcf7c98c8795a448a5b5353f2a6f2b28fdeacc774a6",
"remoteRelease": "/home/digital-human-service/releases/instructors-v2-20260914"
}

+ 11
- 0
reports/instructors-v2-20260914/promotion.json Просмотреть файл

@@ -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"
}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 104
- 0
reports/instructors-v2-20260914/stable-viewer-validation.json Просмотреть файл

@@ -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": []
}
]

+ 91
- 0
tools/finalize_instructors_v2.py Просмотреть файл

@@ -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()

+ 124
- 0
tools/promote_instructors_v2.py Просмотреть файл

@@ -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()

+ 30
- 0
tools/report_instructors_v2.py Просмотреть файл

@@ -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')

+ 121
- 0
tools/retrain_instructors_v2.py Просмотреть файл

@@ -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]()

+ 57
- 0
tools/verify_instructors_v2.mjs Просмотреть файл

@@ -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(); }

+ 53
- 0
tools/verify_instructors_v2_catalog.mjs Просмотреть файл

@@ -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(); }

Загрузка…
Отмена
Сохранить