aiPptVisualTest.html 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <title>AI PPT Visual Test</title>
  7. <style>
  8. body { margin: 0; padding: 24px; background: #dfe4ea; font-family: "Microsoft Yahei", sans-serif; }
  9. h1 { margin: 0 0 20px; font-size: 24px; color: #172231; }
  10. .grid { display: grid; grid-template-columns: repeat(2, 720px); gap: 24px; }
  11. .slide { background: white; box-shadow: 0 8px 24px rgba(17, 34, 51, .16); }
  12. </style>
  13. </head>
  14. <body>
  15. <div id="app"></div>
  16. <script type="module">
  17. import { createApp, h, ref, nextTick, watch } from "vue"
  18. import { lessonRepairFixture } from './fixtures/aiPptLessonRepair.mjs'
  19. import { contentGateFixture } from './fixtures/aiPptContentGate.mjs'
  20. import { liveRegressionFixture } from './fixtures/aiPptLiveRegression.mjs'
  21. import { createPinia, setActivePinia } from "pinia"
  22. import ThumbnailSlide from "/src/views/components/ThumbnailSlide/index.vue"
  23. import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
  24. import { getAiCoursewareTemplate } from "/src/config/aiCoursewareTemplates.ts"
  25. import "/src/assets/styles/global.scss"
  26. import "/src/assets/styles/prosemirror.scss"
  27. import { hydrateSlideBackgroundImages } from "/src/libs/jsonTool.ts"
  28. import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
  29. import { semanticDesignFixture, densityRegressionFixture, heroImageRegressionFixture } from "./fixtures/aiPptSemanticDesign.mjs"
  30. import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
  31. import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
  32. import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
  33. import { typographyFixture } from './fixtures/aiPptTypography.mjs'
  34. import { slideDesignFixture } from './fixtures/aiPptSlideDesign.mjs'
  35. import { inspectAiPptRenderedText } from '/src/utils/aiPptRenderedQuality.ts'
  36. const id = new URLSearchParams(location.search).get("template") || "ethnic-flower"
  37. const page = (pageNo, type, title, layout, points, extra = {}) => ({ pageNo, type, title, bullets: points, studentContent: { points }, imageKeywords: [], visualPlan: { layout }, ...extra })
  38. const outline = {
  39. title: "凤鸣山谷的故事", templateId: id, gradeName: "一年级", textbookName: "音乐一年级上册", unitName: "第一单元 奇妙的声音世界", chapterName: "凤鸣山谷的故事", courseType: "music", style: "cartoon",
  40. pages: [
  41. page(1, "cover", "凤鸣山谷的故事", "cover_scene", ["聆听自然的声音", "发现音乐的强弱变化"]),
  42. page(2, "scene_intro", "山谷里传来了什么声音?", "story_stage", ["观察画面", "模仿你听到的声音"], { interaction: { type: "question", question: "你觉得是谁在歌唱?", options: ["小鸟", "溪水", "风声"] } }),
  43. page(3, "objectives", "今天我们要学会", "goal_cards", ["分辨声音的高低", "感受音乐的强弱", "合作完成声音表演"]),
  44. page(4, "first_listen", "第一次完整聆听", "listen_wave", ["安静听完音乐", "用手势表示声音的远近", "记住最喜欢的声音"], { studentContent: { headline: "闭上眼睛,声音从哪里来?", points: ["安静听完音乐", "用手势表示声音的远近", "记住最喜欢的声音"] } }),
  45. page(5, "work_context", "走进凤鸣山谷", "split_image", ["清晨的山谷十分安静", "鸟鸣由远处慢慢靠近", "溪水一直轻轻流淌"]),
  46. page(6, "lyric_read", "读一读山谷歌谣", "lyric_focus", ["小鸟唱得清脆", "溪水流得轻柔", "山风吹得悠长"]),
  47. page(7, "melody_score", "旋律是怎样行走的?", "score_focus", ["向上表示声音变高", "向下表示声音变低", "平直表示保持稳定"]),
  48. page(8, "compare_listen", "两段声音有什么不同?", "compare_listen", ["比较速度", "比较力度"], { interaction: { type: "compare", question: "哪一段更像远处的鸟鸣?", options: ["第一段更轻", "第二段更强", "第一段更慢", "第二段更快"] } }),
  49. page(9, "performance_activity", "合作表演山谷音乐会", "performance_stage", ["保持自己的声部", "关注指挥手势", "结束时一起收声"], { activity: { task: "用声音演出山谷从清晨到热闹的变化", steps: ["分成鸟鸣、溪水、山风三组", "商量进入顺序", "完整表演并互相评价"] } }),
  50. page(10, "summary", "我的音乐收获", "summary_checklist", ["我能分辨声音的高低", "我能表现声音的强弱", "我能与同伴合作表演"], { learningEvidence: "用一种声音表现你心中的凤鸣山谷" })
  51. ]
  52. }
  53. const scenario = new URLSearchParams(location.search).get("scenario")
  54. const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
  55. const teachingImage = scenario === 'typography' ? await new Promise(async (resolve, reject) => {
  56. try {
  57. const response = await fetch('/scripts/fixtures/assets/pianica-yamaha-p32d.jpg')
  58. if (!response.ok) throw new Error('实物图读取失败')
  59. const reader = new FileReader()
  60. reader.onload = () => resolve(reader.result)
  61. reader.onerror = reject
  62. reader.readAsDataURL(await response.blob())
  63. } catch (error) { reject(error) }
  64. }) : undefined
  65. const fixture = scenario === 'slide-design' ? slideDesignFixture() : scenario === 'typography' ? typographyFixture(teachingImage) : scenario === 'editorial' ? editorialFixture() : scenario === 'live' ? liveRegressionFixture() : scenario === 'content-gate' ? contentGateFixture() : scenario === 'repair' ? lessonRepairFixture(fixtureImage) : scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
  66. : scenario === 'classroom' ? classroomFixture(fixtureImage) : outline
  67. const result = aiCoursewareToSlides(fixture, { templateId: id })
  68. const group = ref(0)
  69. const geometryStatus = ref("正在检查实际文字边界")
  70. const previewColumns = window.innerWidth >= 1100 ? 2 : 1
  71. const previewWidth = Math.floor(Math.min(720, (window.innerHeight - 180) * 8 / 9, (window.innerWidth - 48 - (previewColumns - 1) * 24) / previewColumns))
  72. const presentationPreview = new URLSearchParams(location.search).get('present') === '1'
  73. if (presentationPreview) {
  74. const style = document.createElement('style')
  75. style.textContent = 'body{padding:24px!important;background:#e9edf1!important}main>h1{margin:0 0 14px!important;font-size:22px!important;color:#26334a}main>button{padding:8px 16px;margin:0 8px 18px 0;border:1px solid #c4cbd4;background:white;cursor:pointer;border-radius:4px}#geometry-status,#rendered-quality-status,#export-status{display:none}.grid{margin-bottom:24px}'
  76. document.head.appendChild(style)
  77. }
  78. const app = createApp({
  79. render: () => h("main", [
  80. h("h1", presentationPreview ? `教学色块与配图预览 · ${result.slides.length}页设计样例` : `${getAiCoursewareTemplate(id).name} · 通用版式视觉回归`),
  81. h("div", { id: "geometry-status" }, geometryStatus.value),
  82. ...Array.from({ length: Math.ceil(result.slides.length / 4) }, (_, index) => h('button', { onClick: () => { group.value = index } }, `查看第${index * 4 + 1}—${Math.min(index * 4 + 4, result.slides.length)}页`)),
  83. h("section", { class: "grid", style: { gridTemplateColumns: `repeat(${previewColumns}, ${previewWidth}px)` } }, result.slides.slice(group.value * 4, group.value * 4 + 4).map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: previewWidth })])))
  84. ])
  85. })
  86. const pinia = createPinia()
  87. setActivePinia(pinia)
  88. app.use(pinia)
  89. app.mount("#app")
  90. async function checkRenderedGeometry() {
  91. await nextTick()
  92. await document.fonts.ready
  93. await new Promise(resolve => requestAnimationFrame(resolve))
  94. const errors = []
  95. document.querySelectorAll('.grid .slide').forEach((slide, index) => {
  96. const pageNo = group.value * 4 + index + 1
  97. const texts = [...slide.querySelectorAll('.base-element-text')]
  98. const rects = texts.map(el => el.querySelector('.element-content').getBoundingClientRect())
  99. texts.forEach((el, i) => {
  100. const actual = el.querySelector('.element-content').offsetHeight
  101. if (actual > parseFloat(el.style.height) + 2) errors.push(`${pageNo}:文字高度 ${actual} > ${el.style.height}`)
  102. for (let j = i + 1; j < texts.length; j++) {
  103. const a = rects[i], b = rects[j]
  104. if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1) errors.push(`${pageNo}:文字重叠`)
  105. }
  106. })
  107. })
  108. geometryStatus.value = JSON.stringify({ pages: `${group.value * 4 + 1}-${Math.min(group.value * 4 + 4, result.slides.length)}`, errors })
  109. }
  110. watch(group, checkRenderedGeometry)
  111. checkRenderedGeometry()
  112. if (['editorial', 'typography', 'slide-design'].includes(scenario)) {
  113. const status = document.createElement('p')
  114. status.id = 'rendered-quality-status'
  115. status.textContent = '正在运行实际渲染回归'
  116. document.querySelector('main').prepend(status)
  117. try {
  118. const original = JSON.stringify(result.slides)
  119. const issues = await inspectAiPptRenderedText(result.slides)
  120. const suites = []
  121. for (const [name, sample] of [['live', liveRegressionFixture()], ['content-gate', contentGateFixture()],
  122. ['repair', lessonRepairFixture(fixtureImage)], ['inquiry', inquiryFixture()],
  123. ['model', modelDesignFixture(fixtureImage)], ['density', densityRegressionFixture(fixtureImage)]]) {
  124. const slides = aiCoursewareToSlides(sample, { templateId: 'blank' }).slides
  125. suites.push({ name, pages: slides.length, issues: await inspectAiPptRenderedText(slides) })
  126. }
  127. const damaged = JSON.parse(original)
  128. const text = damaged[0].elements.find(element => element.type === 'text')
  129. Object.assign(text, { left: 100, top: 150, width: 800, height: 60, content: '<div style="font-size:40px"><p>第一段正文</p><p style="font-size:72px">混合字号</p><p>第三段正文</p><p>第四段正文</p></div>' })
  130. damaged[0].elements.push({ ...text, id: 'overlap-regression', top: 240, height: 80, content: '<div style="font-size:40px">这里不应被上一段覆盖</div>' })
  131. const damagedIssues = await inspectAiPptRenderedText([damaged[0]], [9])
  132. const regressions = {
  133. multilineDetected: damagedIssues.some(issue => issue.code === 'rendered_text_overflow' && issue.pageNo === 9),
  134. overlapDetected: damagedIssues.some(issue => issue.code === 'rendered_text_overlap'),
  135. sourceUnchanged: JSON.stringify(result.slides) === original,
  136. probeCleanedUp: document.querySelectorAll('[data-ai-ppt-render-probe]').length === 0
  137. }
  138. status.textContent = JSON.stringify({ passed: issues.length === 0 && suites.every(suite => !suite.issues.length) && Object.values(regressions).every(Boolean), pages: result.slides.length, issues, regressions, suites })
  139. } catch (error) { status.textContent = JSON.stringify({ passed: false, error: error.message }) }
  140. }
  141. if (new URLSearchParams(location.search).get("export") === "1") {
  142. const status = document.createElement("p")
  143. status.id = "export-status"
  144. status.textContent = "正在导出"
  145. document.body.prepend(status)
  146. try {
  147. const workerSources = await Promise.all(["/pptworker/cos-js-sdk-v5.min.js", "/pptworker/upload.js", "/pptworker/pptJson.js"].map(async url => await (await fetch(url)).text()))
  148. const worker = new Worker(URL.createObjectURL(new Blob(workerSources)))
  149. let rpcId = 0
  150. const callbacks = new Map()
  151. let resolveWorkerReady
  152. const workerReady = new Promise(resolve => { resolveWorkerReady = resolve })
  153. let resolveExportDone
  154. let rejectExportDone
  155. const exportDone = new Promise((resolve, reject) => { resolveExportDone = resolve; rejectExportDone = reject })
  156. worker.addEventListener("message", event => {
  157. const data = event.data
  158. if (data?.type === "progress") {
  159. const progress = data.result || {}
  160. if (progress.status === "done" && Number(progress.progress) === 100) resolveExportDone(progress.extra)
  161. if (progress.status === "failed") rejectExportDone(new Error(progress.extra?.errorMsg || "PPT导出失败"))
  162. return
  163. }
  164. if (data?.type !== "RPC") return
  165. if (!data.id) {
  166. if (data.method === "ready") resolveWorkerReady()
  167. return
  168. }
  169. const callback = callbacks.get(data.id)
  170. if (!callback) return
  171. callbacks.delete(data.id)
  172. data.error ? callback.reject(new Error(data.error.message)) : callback.resolve(data.result)
  173. })
  174. const rpc = (method, ...params) => new Promise((resolve, reject) => {
  175. const id = ++rpcId
  176. callbacks.set(id, { resolve, reject })
  177. worker.postMessage({ type: "RPC", id, method, params })
  178. })
  179. await workerReady
  180. await rpc("createTaskController")
  181. const exportSlides = await hydrateSlideBackgroundImages(result.slides)
  182. const json = { width: 1920, height: 1080, slides: exportSlides, theme: { ...result.theme, viewportRatio: 0.5625 } }
  183. const jsonUrl = URL.createObjectURL(new Blob([JSON.stringify(json)], { type: "application/json" }))
  184. await rpc("addCourseWareTask", { type: "downloadCourseware", extra: { id: "ai-ppt-e2e", type: "pptx", outputName: "ai-ppt-e2e.pptx", name: "ai-ppt-e2e", jsonUrl } })
  185. const exported = await exportDone
  186. const sinkUrl = new URLSearchParams(location.search).get("sink")
  187. if (sinkUrl) {
  188. const response = await fetch(sinkUrl, { method: "POST", body: exported.buffer })
  189. if (!response.ok) throw new Error(`测试文件保存失败:HTTP ${response.status}`)
  190. }
  191. URL.revokeObjectURL(jsonUrl)
  192. const pptxUrl = URL.createObjectURL(new Blob([exported.buffer], { type: exported.contentType }))
  193. const link = document.createElement("a")
  194. link.href = pptxUrl
  195. link.download = exported.name
  196. if (!sinkUrl) link.click()
  197. status.textContent = `导出成功:${exported.buffer.byteLength} bytes`
  198. document.title = "EXPORT_DONE"
  199. await rpc("destoryTaskController")
  200. worker.terminate()
  201. } catch (error) {
  202. status.textContent = `导出失败:${error?.message || error}`
  203. document.title = "EXPORT_FAILED"
  204. }
  205. }
  206. </script>
  207. </body>
  208. </html>