Переглянути джерело

test(avatars): preserve v2 retraining and preview verification

main
leiyun 5 дні тому
джерело
коміт
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. BIN
     
  6. BIN
     
  7. BIN
     
  8. BIN
     
  9. BIN
     
  10. +18
    -0
      reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000005/assets/manifest.json
  11. BIN
     
  12. BIN
     
  13. BIN
     
  14. +18
    -0
      reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000006/assets/manifest.json
  15. BIN
     
  16. BIN
     
  17. BIN
     
  18. +18
    -0
      reports/instructors-v2-20260914/backup-local/a1000000000000000000000000000007/assets/manifest.json
  19. BIN
     
  20. +128
    -0
      reports/instructors-v2-20260914/before-platform.json
  21. +59
    -0
      reports/instructors-v2-20260914/before-service.json
  22. BIN
     
  23. BIN
     
  24. BIN
     
  25. BIN
     
  26. +22
    -0
      reports/instructors-v2-20260914/catalog-validation.json
  27. +16
    -0
      reports/instructors-v2-20260914/final-validation.json
  28. BIN
     
  29. BIN
     
  30. BIN
     
  31. BIN
     
  32. BIN
     
  33. BIN
     
  34. +92
    -0
      reports/instructors-v2-20260914/generated-validation.json
  35. +104
    -0
      reports/instructors-v2-20260914/generated-viewer-validation.json
  36. BIN
     
  37. BIN
     
  38. +18
    -0
      reports/instructors-v2-20260914/generated/a1000000000000000000000000000005/assets/manifest.json
  39. BIN
     
  40. BIN
     
  41. BIN
     
  42. +18
    -0
      reports/instructors-v2-20260914/generated/a1000000000000000000000000000006/assets/manifest.json
  43. BIN
     
  44. BIN
     
  45. BIN
     
  46. +18
    -0
      reports/instructors-v2-20260914/generated/a1000000000000000000000000000007/assets/manifest.json
  47. BIN
     
  48. +20
    -0
      reports/instructors-v2-20260914/image-validation.json
  49. +25
    -0
      reports/instructors-v2-20260914/index.html
  50. BIN
     
  51. BIN
     
  52. BIN
     
  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. BIN
     
  59. BIN
     
  60. BIN
     
  61. BIN
     
  62. BIN
     
  63. BIN
     
  64. BIN
     
  65. BIN
     
  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(); }

Завантаження…
Відмінити
Зберегти