yonge 1 неделя назад
Родитель
Сommit
fe07055687

+ 2 - 0
scripts/aiPptOutlineTest.mjs

@@ -7,11 +7,13 @@ try {
   const { aiCoursewareToSlides } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts')
   const outline = { title: '课堂', pages: Array.from({ length: 8 }, (_, i) => ({ pageNo: i + 1, type: i ? 'practice_check' : 'cover', title: `页面${i}`, teachingBrief: '比较声音并模仿', durationMinutes: 5, bullets: ['旧正文'], studentContent: { points: ['旧正文'] }, imageKeywords: [] })) }
   const pages = editableOutlinePages(outline)
+  outline.teacherRequirements = '五年级,喜欢真实乐器图片,保留合奏活动'
   pages[1].page.title = '听辨小挑战'
   pages[1].brief = ' 听两组声音,辨别强弱,再用拍手模仿 '
   pages.splice(2, 0, pages.splice(1, 1)[0])
   const confirmed = confirmedCoursewareOutline(outline, pages)
   assert.deepEqual(inspectConfirmedOutline(confirmed), []) // 无需教师填写目标与分钟数
+  assert.equal(confirmed.teacherRequirements, outline.teacherRequirements)
   assert.equal(confirmed.pages[2].title, '听辨小挑战')
   assert.equal(confirmed.pages[2].teachingBrief, '听两组声音,辨别强弱,再用拍手模仿')
   assert.deepEqual(confirmed.pages.map(p => p.pageNo), [1,2,3,4,5,6,7,8])

+ 31 - 2
scripts/aiPptSelfTest.mjs

@@ -218,7 +218,9 @@ try {
   assert(codes(noResearch).includes("textbook_evidence"))
   const missingAudio = structuredClone(valid)
   missingAudio.pages[4].resourceBindings = []
-  assert(codes(missingAudio).includes("missing_page_audio"))
+  assert(!codes(missingAudio).includes("missing_page_audio"))
+  assert(inspectAiPptQuality(missingAudio, render(missingAudio), preferences).warnings.some(issue => issue.code === "missing_page_audio"))
+  assert(render(missingAudio)[4].remark.includes("补充对应音频"))
   assert(!JSON.stringify(render(missingAudio)).includes("请老师在此补充"))
   const noScore = { ...completeFixture(singingOutline), templateId: "ethnic-flower" }
   noScore.pages[7].resourceBindings = []
@@ -251,7 +253,34 @@ try {
   const withVideo = structuredClone(valid)
   withVideo.pages[7].resourceBindings = [{resourceType:"VIDEO",resourceId:"test-video",contentUrl:"https://example.com/test.mp4",verified:true}]
   assert(render(withVideo)[7].elements.some(element => element.subtype === "elf-video"))
-  console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13,
+  const { getAiPptImageAspectRatios } = await vite.ssrLoadModule("/src/utils/aiPptTemplateEngine.ts")
+  for (const template of AI_COURSEWARE_TEMPLATES) {
+    const illustrated = structuredClone(valid)
+    illustrated.templateId = template.id
+    const target = illustrated.pages[7]
+    target.visualPlan = { layout: "text_focus", imagePurpose: "observation", imageFit: "contain" }
+    target.image = { url: "https://example.com/whole-instrument.png", width: 1800, height: 600, provider: "TEST", reviewStatus: "verified" }
+    target.requiredVisual = true
+    const slides = render(illustrated)
+    const image = slides[7].elements.find(element => element.type === "image" && element.src === target.image.url)
+    assert(image, `${template.id}: 观察题必须显示教学图片`)
+    assert.equal(image.clip, undefined, `${template.id}: 不裁掉教学主体`)
+    assert(Math.abs(image.width / image.height - 3) < 0.001, `${template.id}: 不拉伸教学图片`)
+    assert(slides[7].elements.some(element => elementText(element).includes(target.interaction.question)))
+    assert(!inspectAiPptQuality(illustrated, slides, preferences).errors.some(issue => ["missing_visual_render", "out_of_bounds", "text_overlap"].includes(issue.code)))
+    const ratios = getAiPptImageAspectRatios(template.id)
+    assert(ratios.split_content > 0 && ratios["split_content:media"] > 0)
+    const imageSlot = getTemplateMasterPage(getAiCoursewareTemplateMaster(template.id), "content", "split_content").slots.find(slot => slot.kind === "image" && slot.enabled !== false)
+    assert.equal(ratios["split_content:media"], imageSlot.width / imageSlot.height)
+    if (template.id === "dark-lute") {
+      const body = slides[7].elements.find(element => elementText(element).includes(target.interaction.question))
+      assert.equal(body.defaultColor, "#F7F6F1", "深色背景上的正文必须保持可读")
+    }
+  }
+  const unreviewed = structuredClone(valid)
+  unreviewed.pages[1].image = { url: "https://example.com/scene.png", provider: "TEST", reviewStatus: "unverified" }
+  assert(inspectAiPptQuality(unreviewed, render(unreviewed), preferences).warnings.some(issue => issue.code === "image_review"))
+  console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13, imageTemplateChecks: AI_COURSEWARE_TEMPLATES.length,
     scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
   if (failures.length) process.exitCode = 1
 } finally {

+ 11 - 0
src/api/aiCourseware.ts

@@ -9,6 +9,8 @@ export interface AiCoursewareOutlineQuery {
 }
 
 export interface AiCoursewareGenerateQuery {
+  extraPrompt?: string
+  imageAspectRatios?: Record<string, number>
   id: string
   fromType?: string
   outline: AiCoursewareOutline
@@ -35,6 +37,7 @@ export interface AiCoursewarePreferences {
 }
 
 export interface AiCoursewareOutline {
+  teacherRequirements?: string
   title: string
   templateId?: AiCoursewareTemplateId
   gradeName: string
@@ -82,6 +85,7 @@ export interface AiCoursewareTextbookResearch {
 }
 
 export interface AiCoursewarePage {
+  imageReview?: string
   /** 教师确认的教学安排,指导扩写,不直接作为学生可见正文。 */
   teachingBrief?: string
   pageNo: number
@@ -116,6 +120,11 @@ export interface AiCoursewarePage {
     imageKeywords?: string[]
   }
   visualPlan?: {
+    imagePurpose?: 'scene' | 'observation' | 'comparison' | 'factual' | 'demonstration' | 'decoration' | 'none'
+    mustInclude?: string[]
+    avoidElements?: string[]
+    imageFit?: 'contain' | 'cover'
+    imageAspectRatio?: number
     layout?: string
     visualRole?: string
     focalContent?: string
@@ -138,6 +147,8 @@ export interface AiCoursewarePage {
   }
   imageKeywords: string[]
   image?: {
+    reviewStatus?: 'verified' | 'unverified'
+    reviewReason?: string
     url: string
     keyword: string
     provider: string

+ 3 - 3
src/config/aiCoursewareTemplateMasters.ts

@@ -344,7 +344,7 @@ function createExtractedMaster(template: AiCoursewareTemplate): TemplateMaster {
     coverTitleColor: colors.dark,
     textColor: colors.text,
     mutedColor: colors.muted,
-    surfaceColor: colors.surface,
+    surfaceColor: template.id === "dark-lute" ? "#16485C" : colors.surface,
     borderColor: colors.secondary,
     coverBase: [],
     contentBase: [],
@@ -386,12 +386,12 @@ function createExtractedMaster(template: AiCoursewareTemplate): TemplateMaster {
           ? [shape(0, 0, 1600, 86, "#06364B")]
           : page.base,
         slots,
-        ...(template.id === "dark-lute" && ["cover", "media", "activity", "summary"].includes(page.layout)
+        ...(template.id === "dark-lute"
           ? {
               titleColor: "#D3B270",
               coverTitleColor: "#D3B270",
               mutedColor: "#D5C7AA",
-              textColor: page.layout === "cover" || page.layout === "summary" ? "#F7F6F1" : master.textColor
+              textColor: "#F7F6F1"
             }
           : {})
       }

+ 4 - 2
src/utils/aiPptQuality.ts

@@ -264,9 +264,9 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
         push(issues, "missing_score_render", "error", no, "已绑定的真实乐谱没有进入页面")
       }
     }
-    if (needsAudio(page) && audioBindings(page).length === 0) push(issues, "missing_page_audio", "error", no, "本页听唱任务缺少可播放音频,请补充资源或修改任务")
+    if (needsAudio(page) && audioBindings(page).length === 0) push(issues, "missing_page_audio", "warning", no, "本页听唱任务需手动补充音频,教学安排已保留")
     if (/compare_listen/.test(page.type) && new Set(audioBindings(page).map(item => item.contentUrl)).size < 2) {
-      push(issues, "compare_audio", "error", no, "对比听赏需要两个不同的音频资源,并明确A、B任务")
+      push(issues, "compare_audio", "warning", no, "请为A、B对比任务补充对应音频或片段")
     }
     for (const binding of audioBindings(page)) {
       if (!slide.elements.some(el => el.type === "elf" && (el.subtype === "elf-audio" || el.subtype === "elf-enjoy") && el.src === binding.contentUrl)) {
@@ -274,6 +274,8 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
       }
     }
     if (page.requiredVisual && !usableResourceUrl(page.image?.url) && !scoreBinding(page)) push(issues, "missing_required_visual", "error", no, "本页观察任务缺少必要图片")
+    if (page.imageReview) push(issues, "image_review", "warning", no, page.imageReview)
+    else if (page.image?.reviewStatus === "unverified") push(issues, "image_review", "warning", no, page.image.reviewReason || "配图未完成教学适用性审核,请核对主体、细节与学生年龄是否合适")
     if (page.requiredVisual && usableResourceUrl(page.image?.url) && !slide.elements.some(el => el.type === "image" && el.src === page.image?.url)) push(issues, "missing_visual_render", "error", no, "必要教学图片没有进入页面")
     for (const binding of (page.resourceBindings || []).filter(item => item.resourceType === "VIDEO")) {
       if (binding.verified !== true || !usableResourceUrl(binding.contentUrl) || !slide.elements.some(el => el.type === "elf" && el.subtype === "elf-video" && el.src === binding.contentUrl)) push(issues, "missing_video_render", "error", no, "已选择的视频不可用或没有进入页面")

+ 6 - 2
src/utils/aiPptResources.ts

@@ -38,8 +38,10 @@ export async function inspectAiPptResources(outline: AiCoursewareOutline, slides
       if (!kind || !("src" in element)) return
       try {
         const size = await probe(element.src, kind)
-        // 谱图完整等比放进原槽位,禁止拉伸音符或裁掉小节。
-        if (element.type === "image" && scoreUrls.has(element.src) && size.width && size.height) {
+        // 教学图与谱图完整等比呈现;即便服务端没有图片尺寸,也不拉伸或裁掉主体。
+        const page = outline.pages[index]
+        const teachingImage = page?.image?.url === element.src && (page.requiredVisual || page.visualPlan?.imagePurpose !== "decoration" || page.visualPlan?.imageFit !== "cover")
+        if (element.type === "image" && (scoreUrls.has(element.src) || teachingImage) && size.width && size.height) {
           const scale = Math.min(element.width / size.width, element.height / size.height)
           const width = size.width * scale
           const height = size.height * scale
@@ -47,6 +49,8 @@ export async function inspectAiPptResources(outline: AiCoursewareOutline, slides
           element.top += (element.height - height) / 2
           element.width = width
           element.height = height
+          element.clip = undefined
+          element.fixedRatio = true
         }
       } catch (error) {
         issues.push({ code: "resource_unavailable", severity: "error", pageNo: index + 1,

+ 32 - 9
src/utils/aiPptTemplateEngine.ts

@@ -103,7 +103,10 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   const role = pageRole(page, index)
   const intent = pageRenderIntent(page, role)
   const master = getAiCoursewareTemplateMaster(options.templateId!)
-  const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual ? "split_content" : page.interaction?.question ? "text_focus" : preferredLayout(page, intent))
+  const requested = preferredLayout(page, intent)
+  const teachingImage = hasImage(page) && page.visualPlan?.imagePurpose !== "decoration" && page.visualPlan?.imagePurpose !== "none"
+  const needsImageSlot = role !== "cover" && teachingImage && (page.interaction?.question || ["text_focus", "compare", "activity", "summary", "objectives"].includes(requested || ""))
+  const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual || needsImageSlot ? "split_content" : page.interaction?.question ? "text_focus" : requested)
   const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
   layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
   const masterPage = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
@@ -124,6 +127,21 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   }
 }
 
+/** 传给图片生成器的真实模板槽位比例;媒体行存在时使用独立尺寸。 */
+export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): Record<string, number> {
+  const master = getAiCoursewareTemplateMaster(templateId)
+  const layouts: Record<string, string> = { cover: "cover_scene", hero: "hero_focus", media: "media_focus", split: "split_content", text: "text_focus", diagram: "diagram_focus", compare: "compare", activity: "activity", summary: "summary", objectives: "objectives" }
+  const ratios: Record<string, number> = {}
+  for (const page of master.pages) {
+    for (const showMedia of [true, false]) {
+      const adapted = adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia })
+      const slot = adapted.slots.find(slot => slot.kind === "image" && slot.enabled !== false)
+      if (slot) ratios[layouts[page.layout] + (showMedia ? ":media" : "")] = slot.width / slot.height
+    }
+  }
+  return ratios
+}
+
 function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, role: PageRole = masterPage.roles[0], intent = pageRenderIntent(page, role)): PPTElement[] {
   master = {
     ...master,
@@ -783,7 +801,8 @@ function slideRemark(page: AiCoursewarePage) {
   const answer = page.interaction?.answer ? `参考答案:${page.interaction.answer}` : ""
   const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
   const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
-  return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources]
+  const mediaReminder = /listen|sing/.test(page.type) && audioBindings(page).length === 0 ? "资源准备:请为本页听唱任务补充对应音频。" : ""
+  return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
     .map(cleanText)
     .filter(Boolean)
     .filter((item, index, all) => all.indexOf(item) === index)
@@ -792,7 +811,7 @@ function slideRemark(page: AiCoursewarePage) {
 
 function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
   const resolvedTemplateId = templateId || recommendAiCoursewareTemplate(outline).id
-  return { ...getAiCoursewarePalette(resolvedTemplateId, variant) }
+  return { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) }
 }
 
 function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground {
@@ -851,19 +870,23 @@ function textElement(params: {
 function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
   const width = page.image?.width || 0
   const height = page.image?.height || 0
+  const contain = page.requiredVisual || page.visualPlan?.imagePurpose !== "decoration" || page.visualPlan?.imageFit !== "cover"
+  const scale = width > 0 && height > 0 ? Math.min(rect.width / width, rect.height / height) : 0
+  const fittedWidth = contain && scale ? width * scale : rect.width
+  const fittedHeight = contain && scale ? height * scale : rect.height
   return {
     id: nanoid(10),
     type: "image",
-    left: rect.left,
-    top: rect.top,
-    width: rect.width,
-    height: rect.height,
+    left: rect.left + (rect.width - fittedWidth) / 2,
+    top: rect.top + (rect.height - fittedHeight) / 2,
+    width: fittedWidth,
+    height: fittedHeight,
     rotate: 0,
     src: page.image?.url || "",
-    fixedRatio: false,
+    fixedRatio: Boolean(contain),
     radius: rect.radius || 0,
     shadow: rect.radius ? softShadow() : undefined,
-    clip: width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined
+    clip: !contain && width > 0 && height > 0 ? { shape: "rect", range: coverRange(width, height, rect.width, rect.height) } : undefined
   }
 }
 

+ 3 - 0
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -206,6 +206,7 @@ import {
 } from "@/api/aiCourseware"
 import { AI_COURSEWARE_TEMPLATES, recommendAiCoursewareTemplate, getAiCoursewareTemplate } from "@/config/aiCoursewareTemplates"
 import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
+import { getAiPptImageAspectRatios } from "@/utils/aiPptTemplateEngine"
 import { inspectAiPptQuality, type AiPptQualityIssue } from "@/utils/aiPptQuality"
 import { inspectAiPptResources } from "@/utils/aiPptResources"
 import { templateAssetPath } from "@/utils/templateAssetUrl"
@@ -479,6 +480,8 @@ async function startGeneration() {
       id: pptWork.id,
       fromType: source(),
       outline: plan,
+      extraPrompt: extraPrompt.value.trim(),
+      imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
       templateId: templateId.value,
       outlineConfirmed: true,
       outlineMode: "teaching_plan",