templates.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { copyFile, writeFile, mkdir } from 'node:fs/promises'
  2. import { resolve, join } from 'node:path'
  3. import { randomUUID } from 'node:crypto'
  4. import type { CourseTemplate } from '../src/types.js'
  5. export const COURSE_TEMPLATES: CourseTemplate[] = [
  6. {
  7. id: 'party-building-standard',
  8. name: '《坚持人民至上根本立场》党课视频',
  9. category: '党政专题',
  10. audience: '党员干部 / 青年理论学习小组',
  11. tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
  12. badge: '官方推荐示范',
  13. summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
  14. description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
  15. 【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
  16. 【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。
  17. 【语速与语气】语速 210—225 汉字/分钟;句间停顿 0.3—0.5 秒,段落转折 0.6—0.9 秒,章节切换 1.2—1.8 秒;语气正式、平稳、有教学交流感。
  18. 【交付规格】1920×1080、25fps、H.264/AAC、48kHz 立体声(1080p 高清)。`,
  19. keySpecs: {
  20. resolution: '1080P (1920×1080) 25fps',
  21. aspectRatio: '16:9 横版',
  22. speechSpeed: '210—225 汉字/分钟',
  23. presenterRatio: '30% 讲师出镜 / 70% 全屏课件',
  24. voiceTone: '正式、清晰、平稳教学感'
  25. },
  26. taskBrief: {
  27. focalPoints: [
  28. '视频质量 1080p 高清,少 AI 味,人物形象清晰,手要露出来,表情自然',
  29. 'AI 生成音频要跟原稿完全一致,严禁读错字、漏字或擅自改写'
  30. ],
  31. goals: [
  32. '口播稿:《坚持人民至上根本立场 课程讲稿》,字幕原则上与审定讲稿完全一致',
  33. '课件:坚持人民至上根本立场.pptx,配合讲稿完成逻辑严密的同步展示',
  34. '镜头逻辑:结构上“讲师讲述—PPT论证—讲师回场—章节转场”保持庄重感',
  35. '技术参数:1920×1080、25fps、H.264/AAC、48kHz 立体声'
  36. ],
  37. cameraAndPacing: [
  38. '讲师近景与中景切换通常约 20—90 秒,避免静态数字人长时间无动作',
  39. '单页 PPT 始终全屏展示(无需画中画),常驻通常约 10—45 秒',
  40. '画面切换应落在完整句、自然停顿或主题转折处,严禁半句话中间切镜头',
  41. '句间停顿 0.3—0.5 秒,段落转折 0.6—0.9 秒,章节切换 1.2—1.8 秒'
  42. ],
  43. acceptanceCriteria: [
  44. '讲稿无漏句、错句;全部数字、年份、人名、地名读音准确无误',
  45. 'PPT 使用顺序、讲述内容和动画出现点严格一致,每页均可追溯',
  46. '章节层级清楚,各一级部分与小节均有明确进入与收束'
  47. ]
  48. },
  49. instructors: [
  50. {
  51. name: '特邀党建专家 张教授',
  52. organization: '中共党史与理论研究中心',
  53. introduction: '资深党建研究专家、特聘理论导师,长年从事党史党建理论研究与党课教学,语调稳健、讲授权威亲和。',
  54. sampleImageFile: '人物_站.jpg'
  55. }
  56. ],
  57. episodes: [
  58. {
  59. episodeNumber: 1,
  60. title: '第一讲:江山就是人民,人民就是江山',
  61. summary: '阐释人民至上的理论渊源与历史必然性。',
  62. lectureNotes: '同志们好!今天我们开始第一讲的专题学习。坚持人民至上,是我们党的根本立场,也是马克思主义唯物史观的集中体现……'
  63. },
  64. {
  65. episodeNumber: 2,
  66. title: '第二讲:把人民对美好生活的向往作为奋斗目标',
  67. summary: '结合新时代生动实践,论述高质量发展中保障和改善民生。',
  68. lectureNotes: '在第二讲中,我们将从实践维度来深入领会。治国有常,利民为本。如何把发展成果转化为人民群众实实在在的幸福感……'
  69. },
  70. {
  71. episodeNumber: 3,
  72. title: '第三讲:紧紧依靠人民创造历史伟业',
  73. summary: '总结提炼群众路线与青年党员担当使命。',
  74. lectureNotes: '第三讲,我们聚焦新时代青年理论骨干的使命担当。历史是人民书写的,一切成就归功于人民……'
  75. }
  76. ],
  77. assets: [
  78. {
  79. originalName: '坚持人民至上根本立场 课程讲稿(审定版).docx',
  80. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  81. sizeText: '156 KB',
  82. description: '第一讲口播讲稿,字词及发音注音已审定',
  83. episodeNumber: 1
  84. },
  85. {
  86. originalName: '第一讲_坚持人民至上根本立场.pptx',
  87. mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  88. sizeText: '40.5 MB',
  89. description: '第一讲16:9 高清论证课件,对应核心要点',
  90. episodeNumber: 1
  91. },
  92. {
  93. originalName: '第二讲_美好生活奋斗目标讲义.docx',
  94. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  95. sizeText: '142 KB',
  96. description: '第二讲讲稿与案例参考',
  97. episodeNumber: 2
  98. },
  99. {
  100. originalName: 'teacher_voice_reference_replace.wav',
  101. mimeType: 'audio/wav',
  102. sizeText: '8.2 MB',
  103. description: '讲师原声录音切片,全套课程声纹克隆通用参考'
  104. }
  105. ]
  106. },
  107. {
  108. id: 'higher-edu-calculus',
  109. name: '《深入浅出微积分:极限与导数之美》公开课',
  110. category: '高校公开课',
  111. audience: '理工科大一新生 / 考研复习群体',
  112. tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
  113. badge: '高校示范',
  114. summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',
  115. description: `【制作目标】打造通俗易懂、几何直观性强的大学数学精品微课,适合线上慕课与翻转课堂。
  116. 【形式结构】知识点引言—几何直观演示—严密数学推导—典型例题精讲—思考总结。
  117. 【运镜与节奏】讲师半身讲述约 40%,公式板书与动态动画演示约 60%;支持黑板/手写板与讲师同框画中画。
  118. 【语速与语气】语速 190—210 汉字/分钟,关键公式推导处适当放慢并留白停顿;语气启发温和、循序渐进。
  119. 【交付规格】1080P 60fps 高帧率(保证公式动画平滑),立体声 48kHz。`,
  120. keySpecs: {
  121. resolution: '1080P (1920×1080) 60fps',
  122. aspectRatio: '16:9 横版',
  123. speechSpeed: '190—210 汉字/分钟',
  124. presenterRatio: '40% 讲师半身 / 60% 动效课件板书',
  125. voiceTone: '启发亲切、循序渐进'
  126. },
  127. taskBrief: {
  128. focalPoints: [
  129. '公式与图形动画平滑无卡顿,60fps 高帧率输出',
  130. '讲解与手写板书笔迹同步出现,重点定理高亮标红'
  131. ],
  132. goals: [
  133. '讲稿:《微积分第一讲:极限的直观与定义》讲义',
  134. '课件:微积分动效演示.pptx(含动画与函数图像)',
  135. '音画同步:讲师语调配合板书推进节奏'
  136. ],
  137. cameraAndPacing: [
  138. '引言部分讲师中景亲切互动,概念阐述时平滑切至动态图像',
  139. '推导定理时采用双画面布局(左侧公式、右侧讲师视线交互)'
  140. ],
  141. acceptanceCriteria: [
  142. '数学符号、希腊字母及公式读法严谨规范',
  143. '函数曲线动画与讲述点完全对齐'
  144. ]
  145. },
  146. instructors: [
  147. {
  148. name: '林副教授',
  149. organization: '数学与应用数学学院',
  150. introduction: '国家级精品课程主讲教师,擅长以直观几何图像剖析抽象数学概念,教学风格深入浅出。',
  151. sampleImageFile: '人物_坐.jpg'
  152. }
  153. ],
  154. episodes: [
  155. {
  156. episodeNumber: 1,
  157. title: '第 1 集:极限的直观与严密定义 (ε-δ 语言)',
  158. summary: '从割线切线问题切入,通过动画直观展现数列与函数极限。',
  159. lectureNotes: '同学们好!欢迎来到《深入浅出微积分》第1集。今天我们要探索微积分大厦的基石——极限。首先我们来看一个割线逼近切线的动态图像……'
  160. },
  161. {
  162. episodeNumber: 2,
  163. title: '第 2 集:导数的几何意义与瞬时变化率',
  164. summary: '剖析速度与切线斜率的统一物理数学背景,推导核心求导法则。',
  165. lectureNotes: '在第2集中,我们将从瞬时速度的物理问题出发,正式引出导数定义。请大家看屏幕左侧的割线斜率变化……'
  166. },
  167. {
  168. episodeNumber: 3,
  169. title: '第 3 集:微分中值定理与函数单调性',
  170. summary: '罗尔定理与拉格朗日中值定理的几何证明及在极值分析中的应用。',
  171. lectureNotes: '第3集我们将迎来微分学的核心支柱——拉格朗日中值定理。它是联系局部导数与整体增量的桥梁……'
  172. }
  173. ],
  174. assets: [
  175. {
  176. originalName: '第1集_极限直观与定义讲稿.docx',
  177. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  178. sizeText: '98 KB',
  179. description: '第1集课堂讲稿与板书推导脚本',
  180. episodeNumber: 1
  181. },
  182. {
  183. originalName: '第1集_极限动效演示课件.pptx',
  184. mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  185. sizeText: '18.6 MB',
  186. description: '第1集16:9 动态数学图像课件',
  187. episodeNumber: 1
  188. },
  189. {
  190. originalName: '第2集_导数几何意义讲稿.docx',
  191. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  192. sizeText: '112 KB',
  193. description: '第2集导数与切线推导手稿',
  194. episodeNumber: 2
  195. },
  196. {
  197. originalName: '微积分全课程教学大纲与符号规范.pdf',
  198. mimeType: 'application/pdf',
  199. sizeText: '1.2 MB',
  200. description: '全课程通用大纲与板书排版技术标准'
  201. }
  202. ]
  203. }
  204. ]
  205. export async function populateCourseFromTemplate(
  206. pool: any,
  207. courseId: string,
  208. templateId: string,
  209. uploadDir: string,
  210. sampleDir: string
  211. ) {
  212. const template = COURSE_TEMPLATES.find((t) => t.id === templateId) || COURSE_TEMPLATES[0]
  213. // 1. 填充讲师
  214. for (const inst of template.instructors) {
  215. let storedImageName: string | null = null
  216. let origImageName: string | null = null
  217. let mimeType: string | null = null
  218. if (inst.sampleImageFile) {
  219. try {
  220. const sourcePath = resolve(sampleDir, inst.sampleImageFile)
  221. storedImageName = `instructor-${randomUUID()}.jpg`
  222. const destPath = resolve(uploadDir, storedImageName)
  223. await copyFile(sourcePath, destPath)
  224. origImageName = inst.sampleImageFile
  225. mimeType = 'image/jpeg'
  226. } catch (err) {
  227. console.error('Failed to copy sample instructor image:', err)
  228. }
  229. }
  230. await pool.query(
  231. `INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type)
  232. VALUES($1, $2, $3, $4, $5, $6, $7, $8)`,
  233. [
  234. randomUUID(),
  235. courseId,
  236. inst.name,
  237. inst.organization,
  238. inst.introduction,
  239. origImageName,
  240. storedImageName,
  241. mimeType
  242. ]
  243. )
  244. }
  245. // 2. 填充预设分集 (Episodes)
  246. const episodeMap = new Map<number, string>() // episodeNumber -> episodeId
  247. if (template.episodes && template.episodes.length > 0) {
  248. for (const ep of template.episodes) {
  249. const epId = randomUUID()
  250. episodeMap.set(ep.episodeNumber, epId)
  251. await pool.query(
  252. `INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes)
  253. VALUES($1, $2, $3, $4, $5, $6)`,
  254. [epId, courseId, ep.episodeNumber, ep.title, ep.summary || '', ep.lectureNotes || '']
  255. )
  256. }
  257. }
  258. // 3. 填充示例课件资产(支持分配到指定分集或作为通用资产)
  259. for (const asset of template.assets) {
  260. const ext = asset.originalName.split('.').pop() || 'dat'
  261. const storedName = `${courseId}-${randomUUID()}.${ext}`
  262. const destPath = resolve(uploadDir, storedName)
  263. const targetEpId = asset.episodeNumber ? episodeMap.get(asset.episodeNumber) || null : null
  264. // 创建示例占位内容文件
  265. const sampleContent = `=== 星痕智能课程工坊 示范资产 ===\n课程名称:${template.name}\n资产名称:${asset.originalName}\n所属分集:${asset.episodeNumber ? `第 ${asset.episodeNumber} 集` : '课程通用素材'}\n类型描述:${asset.description || '示例教学资料'}\n\n该文件为系统预设课程示例,用户可作为制作任务书与课件标准进行参考。`
  266. await writeFile(destPath, Buffer.from(sampleContent, 'utf8'))
  267. // 虚拟大小 (参考展示)
  268. let approxSize = 1024 * 256
  269. if (asset.sizeText.includes('MB')) {
  270. approxSize = Math.round(parseFloat(asset.sizeText) * 1024 * 1024)
  271. } else if (asset.sizeText.includes('KB')) {
  272. approxSize = Math.round(parseFloat(asset.sizeText) * 1024)
  273. }
  274. await pool.query(
  275. `INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type)
  276. VALUES($1, $2, $3, $4, $5, $6, $7)`,
  277. [randomUUID(), courseId, targetEpId, asset.originalName, storedName, approxSize, asset.mimeType]
  278. )
  279. }
  280. }