@oh-story/dsh 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -11
- package/lib/client.js +85 -35
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +41 -36
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
- package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
- package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
- package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
- package/lib/index.js +16 -7
- package/lib/index.js.map +2 -2
- package/lib/novel-to-game/README.md +98 -44
- package/lib/novel-to-game/README_ZH.md +100 -46
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
- package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
- package/lib/novel-to-game/manifest.json +303 -78
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
- package/lib/types/client/drama-production-view.d.ts +11 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +1 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -1
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +7 -2
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/types/video-project.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +108 -1
- package/lib/video-recap/README.en.md +18 -14
- package/lib/video-recap/README.md +18 -14
- package/lib/video-recap/manifest.json +297 -321
- package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
- package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
- package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
- package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
- package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
- package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
- package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
- package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
- package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
- package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
- package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
- package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
- package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
- package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
- package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
- package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
- package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
- package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
- package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
- package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
- package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
- package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
- package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
- package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
- package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
- package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
- package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
- package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
- package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
- package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
- package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
- package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
- package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
- package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
- package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
- package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
- package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
- package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
- package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
- package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
- package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
- package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
- package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
- package/lib/video-recap/skills/video-script/SKILL.md +9 -7
- package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
- package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
- package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
- package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
- package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
- package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
- package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
- package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
- package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
- package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
- package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
- package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
- package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
- package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
- package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
- package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
- package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
- package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
- package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
- package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
- package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
- package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
- package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
- package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
- package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
- package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
- package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
- package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
- package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
- package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
- package/package.json +45 -45
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
- package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
- package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
- package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
- package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
- package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
- package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
- package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
- package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
- package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
- package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
- /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""R1–R8: mechanically keep source facts out of the transferable production reference.
|
|
2
|
+
|
|
3
|
+
`check` validates the agent's single file (reference_breakdown.json) against the cached
|
|
4
|
+
measurements and the optional understanding artifacts, computes `derived` in memory, and
|
|
5
|
+
returns {derived, errors, warnings}. `export` writes production_reference.json only when there
|
|
6
|
+
are zero errors, then re-scans the serialized result (R8).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from lib import read_json, write_json
|
|
14
|
+
from reference_measure import EDGE_S, MEASUREMENTS_FILE
|
|
15
|
+
from reference_profile import (
|
|
16
|
+
CUT_MATCH_S, OWNERS, apply_cut_fixes, build_production, derive, is_resolved, resolve)
|
|
17
|
+
|
|
18
|
+
BREAKDOWN_FILE = "reference_breakdown.json"
|
|
19
|
+
BREAKDOWN_SCHEMA = "video-reference.breakdown.v1"
|
|
20
|
+
DIMENSIONS = ("narrative_structure", "pacing", "shots_editing", "narration_subtitles", "audio_visual")
|
|
21
|
+
APPLIES_TO = ("story_plan", "visual_audio_board", "clip_plan", "narration", "style_card")
|
|
22
|
+
FUNCTIONS = ("hook", "setup", "turn", "escalation", "payoff")
|
|
23
|
+
NARRATION_JOBS = ("context", "causal_link", "foreshadow", "interpretation", "transition")
|
|
24
|
+
TARGET_ROOTS = ("shots", "loudness", "derived")
|
|
25
|
+
# Objects a target may export whole; every other target must be a numeric leaf. No list indexing,
|
|
26
|
+
# and first_original_at is exported as {fraction} only (its absolute `s` stays local).
|
|
27
|
+
_TARGET_OBJECTS = re.compile(
|
|
28
|
+
r"derived\.(?:structure|first_original_at|narration_jobs|by_owner\.[a-z_]+|by_section\.[a-z]+)")
|
|
29
|
+
GAP_S = 0.5
|
|
30
|
+
T_EPSILON_S = 0.05
|
|
31
|
+
CJK_RUN_MIN = 8
|
|
32
|
+
LATIN_RUN_MIN = 5
|
|
33
|
+
|
|
34
|
+
_KEYS = {
|
|
35
|
+
"top": {"schema", "labels", "source_facts", "methods", "skipped_dimensions"},
|
|
36
|
+
"labels": {"audio_spans", "sections", "subtitles", "basis", "cut_fixes"},
|
|
37
|
+
"cut_fixes": {"add", "remove"},
|
|
38
|
+
"span": {"start", "end", "owner", "narration_job"},
|
|
39
|
+
"section": {"start", "end", "function"},
|
|
40
|
+
"subtitles": {"burned", "max_lines", "marks_original", "evidence_t"},
|
|
41
|
+
"fact": {"id", "dimension", "statement", "t", "measure", "entities"},
|
|
42
|
+
"method": {"id", "dimension", "rule", "applies_when", "avoid_when", "applies_to", "evidence", "targets"},
|
|
43
|
+
"target": {"from"},
|
|
44
|
+
}
|
|
45
|
+
_REQUIRED = {
|
|
46
|
+
"top": {"schema", "labels"},
|
|
47
|
+
"labels": {"audio_spans", "sections"},
|
|
48
|
+
"span": {"start", "end", "owner"},
|
|
49
|
+
"section": {"start", "end", "function"},
|
|
50
|
+
"subtitles": {"burned", "max_lines"},
|
|
51
|
+
"fact": {"id", "dimension", "statement", "entities"},
|
|
52
|
+
"method": {"id", "dimension", "rule", "applies_to", "evidence"},
|
|
53
|
+
"target": {"from"},
|
|
54
|
+
}
|
|
55
|
+
METHOD_TEXT_FIELDS = ("rule", "applies_when", "avoid_when")
|
|
56
|
+
BANNED_EXPORT_KEYS = {"source_facts", "labels", "entities", "evidence", "statement", "from", "path"}
|
|
57
|
+
_CJK = re.compile(r"[㐀-鿿豈-]+")
|
|
58
|
+
_WORD = re.compile(r"[A-Za-z0-9']+")
|
|
59
|
+
# Python's \b treats CJK as word characters, so "在1:23处" has no boundary before "1"; use
|
|
60
|
+
# digit/ASCII lookarounds instead. Path segments are ASCII so prose like "旁白/原声/音乐" stays legal.
|
|
61
|
+
_NUMERAL = "[0-9一二三四五六七八九十百零两]+"
|
|
62
|
+
_TIMECODE = re.compile(
|
|
63
|
+
rf"(?<![\d:])\d{{1,2}}:\d{{2}}(?::\d{{2}})?(?![\d:])|第\s*{_NUMERAL}\s*秒|{_NUMERAL}\s*分钟?\s*{_NUMERAL}\s*秒")
|
|
64
|
+
# One-segment absolute paths only for well-known roots, so prose like "原声/BGM" stays legal.
|
|
65
|
+
_ABS_PATH = re.compile(
|
|
66
|
+
r"(?<![A-Za-z0-9_.])/(?:[A-Za-z0-9_.-]+/)+[\w.-]*|~/[\w.-]+|(?<![A-Za-z0-9_])[A-Za-z]:[\\/][\w.-]+"
|
|
67
|
+
r"|(?<![A-Za-z0-9_.])/(?:tmp|Users|home|var|private|mnt|Volumes|opt|srv|root|data)(?![\w.-])")
|
|
68
|
+
_SPACE = re.compile(r"\s+")
|
|
69
|
+
# Short quoted terms in background research: titles, places, organisations, quoted lines.
|
|
70
|
+
_QUOTED = re.compile(r"《([^》]{2,8})》|「([^」]{2,8})」|“([^”]{2,8})”|‘([^’]{2,8})’|'([^'\s]{2,8})'")
|
|
71
|
+
_NUMBER_IN_RULE = re.compile(r"\d+(?:\.\d+)?\s*(?:秒|%|%|字)")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _num(value):
|
|
75
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _closed(obj, kind, where, errors):
|
|
79
|
+
"""R1 closed key set + required keys; returns True when `obj` is a dict."""
|
|
80
|
+
if not isinstance(obj, dict):
|
|
81
|
+
errors.append(f"R1 {where}: 必须是对象")
|
|
82
|
+
return False
|
|
83
|
+
for key in sorted(set(obj) - _KEYS[kind]):
|
|
84
|
+
errors.append(f"R1 {where}: 不允许的键 {key!r}")
|
|
85
|
+
for key in sorted(_REQUIRED.get(kind, set()) - set(obj)):
|
|
86
|
+
errors.append(f"R1 {where}: 缺少键 {key!r}")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _timed(items, kind, enum_key, enum, errors):
|
|
91
|
+
"""R1 for spans/sections; returns the well-formed items for R2 and derive()."""
|
|
92
|
+
good = []
|
|
93
|
+
for i, item in enumerate(items if isinstance(items, list) else []):
|
|
94
|
+
where = f"labels.{'audio_spans' if kind == 'span' else 'sections'}[{i}]"
|
|
95
|
+
if not _closed(item, kind, where, errors):
|
|
96
|
+
continue
|
|
97
|
+
ok = _num(item.get("start")) and _num(item.get("end"))
|
|
98
|
+
if not ok:
|
|
99
|
+
errors.append(f"R1 {where}: start/end 必须是数字")
|
|
100
|
+
if item.get(enum_key) not in enum:
|
|
101
|
+
errors.append(f"R1 {where}: {enum_key} 必须是 {'|'.join(enum)},不是 {item.get(enum_key)!r}")
|
|
102
|
+
ok = False
|
|
103
|
+
job = item.get("narration_job")
|
|
104
|
+
if "narration_job" in item and (item.get("owner") != "narration" or job not in NARRATION_JOBS):
|
|
105
|
+
errors.append(f"R1 {where}: narration_job 只用于 narration 段,取值 {'|'.join(NARRATION_JOBS)}")
|
|
106
|
+
if ok:
|
|
107
|
+
good.append(item)
|
|
108
|
+
return good
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_cover(items, name, duration, errors):
|
|
112
|
+
"""R2: sorted, no overlap, gaps <= 0.5 s, covering [0, duration] within 0.5 s."""
|
|
113
|
+
if not items:
|
|
114
|
+
errors.append(f"R2 labels.{name}: 不能为空")
|
|
115
|
+
return
|
|
116
|
+
for i, item in enumerate(items):
|
|
117
|
+
if item["start"] >= item["end"]:
|
|
118
|
+
errors.append(f"R2 labels.{name}[{i}]: start 必须小于 end")
|
|
119
|
+
if item["end"] > duration + GAP_S:
|
|
120
|
+
errors.append(f"R2 labels.{name}[{i}]: end 超出时长 {duration}")
|
|
121
|
+
if i:
|
|
122
|
+
prev = items[i - 1]
|
|
123
|
+
if item["start"] < prev["end"] - 1e-6:
|
|
124
|
+
errors.append(f"R2 labels.{name}[{i}]: 与上一段重叠或未按时间排序")
|
|
125
|
+
elif item["start"] - prev["end"] > GAP_S:
|
|
126
|
+
errors.append(f"R2 labels.{name}[{i}]: 与上一段间隙 {item['start'] - prev['end']:.2f}s > {GAP_S}s")
|
|
127
|
+
if items[0]["start"] > GAP_S or items[-1]["end"] < duration - GAP_S:
|
|
128
|
+
errors.append(f"R2 labels.{name}: 必须覆盖 [0, {duration}](首尾容差 {GAP_S}s)")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _check_cut_fixes(fixes, cuts, duration, errors):
|
|
132
|
+
"""R1/R2: removals name a detected cut, additions a cut the detector did not report."""
|
|
133
|
+
if not _closed(fixes, "cut_fixes", "labels.cut_fixes", errors):
|
|
134
|
+
return
|
|
135
|
+
for key in ("add", "remove"):
|
|
136
|
+
values = fixes.get(key, [])
|
|
137
|
+
if not isinstance(values, list) or not all(_num(v) for v in values):
|
|
138
|
+
errors.append(f"R1 labels.cut_fixes.{key}: 必须是秒数列表")
|
|
139
|
+
continue
|
|
140
|
+
for t in values:
|
|
141
|
+
near = any(abs(t - c) <= CUT_MATCH_S for c in cuts)
|
|
142
|
+
if not EDGE_S < t < duration - EDGE_S:
|
|
143
|
+
errors.append(f"R2 labels.cut_fixes.{key}: {t} 不在 (0, {duration}) 内")
|
|
144
|
+
elif key == "remove" and not near:
|
|
145
|
+
errors.append(f"R2 labels.cut_fixes.remove: {t} 附近 ±{CUT_MATCH_S}s 内没有测得的切点")
|
|
146
|
+
elif key == "add" and near:
|
|
147
|
+
errors.append(f"R2 labels.cut_fixes.add: {t} 已是测得的切点(±{CUT_MATCH_S}s)")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_labels(labels, duration, errors, cuts=()):
|
|
151
|
+
if not _closed(labels, "labels", "labels", errors):
|
|
152
|
+
return [], []
|
|
153
|
+
spans = _timed(labels.get("audio_spans"), "span", "owner", OWNERS, errors)
|
|
154
|
+
sections = _timed(labels.get("sections"), "section", "function", FUNCTIONS, errors)
|
|
155
|
+
_check_cover(spans, "audio_spans", duration, errors)
|
|
156
|
+
_check_cover(sections, "sections", duration, errors)
|
|
157
|
+
if "cut_fixes" in labels:
|
|
158
|
+
_check_cut_fixes(labels["cut_fixes"], cuts, duration, errors)
|
|
159
|
+
subtitles = labels.get("subtitles")
|
|
160
|
+
if subtitles is not None and _closed(subtitles, "subtitles", "labels.subtitles", errors):
|
|
161
|
+
where = "R1 labels.subtitles"
|
|
162
|
+
if "burned" in subtitles and not isinstance(subtitles["burned"], bool):
|
|
163
|
+
errors.append(f"{where}.burned: 必须是 true/false")
|
|
164
|
+
lines = subtitles.get("max_lines")
|
|
165
|
+
if "max_lines" in subtitles and (not isinstance(lines, int) or isinstance(lines, bool) or lines < 1):
|
|
166
|
+
errors.append(f"{where}.max_lines: 必须是 ≥1 的整数")
|
|
167
|
+
if "marks_original" in subtitles and not isinstance(subtitles["marks_original"], str):
|
|
168
|
+
errors.append(f"{where}.marks_original: 必须是字符串")
|
|
169
|
+
evidence_t = subtitles.get("evidence_t", [])
|
|
170
|
+
if not isinstance(evidence_t, list):
|
|
171
|
+
errors.append(f"{where}.evidence_t: 必须是数字列表")
|
|
172
|
+
evidence_t = []
|
|
173
|
+
for t in evidence_t:
|
|
174
|
+
if not _num(t) or not 0 <= t <= duration:
|
|
175
|
+
errors.append(f"R2 labels.subtitles.evidence_t: {t!r} 不在 [0, {duration}] 内")
|
|
176
|
+
return spans, sections
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_ids(items, prefix, where, errors):
|
|
180
|
+
seen = set()
|
|
181
|
+
for i, item in enumerate(items):
|
|
182
|
+
ident = item.get("id")
|
|
183
|
+
if not isinstance(ident, str) or not re.fullmatch(rf"{prefix}\d+", ident):
|
|
184
|
+
errors.append(f"R1 {where}[{i}]: id 必须形如 {prefix}1")
|
|
185
|
+
elif ident in seen:
|
|
186
|
+
errors.append(f"R1 {where}[{i}]: id {ident} 重复")
|
|
187
|
+
seen.add(ident)
|
|
188
|
+
return seen
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _anchor_ok(path, measurements, derived):
|
|
192
|
+
"""A `measure` anchor: resolves under shots/loudness/derived and is not a string leaf."""
|
|
193
|
+
if not isinstance(path, str) or path.split(".")[0] not in TARGET_ROOTS:
|
|
194
|
+
return False
|
|
195
|
+
value = resolve(path, measurements, derived)
|
|
196
|
+
return is_resolved(value) and not isinstance(value, str)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_facts(facts, measurements, derived, duration, errors):
|
|
200
|
+
"""R1 + R3: every fact is anchored to a time range or a resolvable measurement."""
|
|
201
|
+
for fact in facts:
|
|
202
|
+
where = f"source_facts.{fact.get('id')}"
|
|
203
|
+
if fact.get("dimension") not in DIMENSIONS:
|
|
204
|
+
errors.append(f"R1 {where}: dimension 必须是 {'|'.join(DIMENSIONS)}")
|
|
205
|
+
if not isinstance(fact.get("statement"), str) or not fact.get("statement").strip():
|
|
206
|
+
errors.append(f"R1 {where}: statement 不能为空")
|
|
207
|
+
if not isinstance(fact.get("entities"), list) or not all(isinstance(e, str) for e in fact.get("entities", [])):
|
|
208
|
+
errors.append(f"R3 {where}: 必须显式写 entities 列表(可为空)")
|
|
209
|
+
if ("t" in fact) == ("measure" in fact):
|
|
210
|
+
errors.append(f"R3 {where}: t 与 measure 必须二选一")
|
|
211
|
+
elif "t" in fact:
|
|
212
|
+
t = fact["t"]
|
|
213
|
+
if not (isinstance(t, list) and len(t) == 2 and all(_num(x) for x in t)
|
|
214
|
+
and 0 <= t[0] < t[1] <= duration + T_EPSILON_S):
|
|
215
|
+
errors.append(f"R3 {where}: t 必须是 [a, b] 且 0 ≤ a < b ≤ {duration}")
|
|
216
|
+
else:
|
|
217
|
+
paths = fact["measure"]
|
|
218
|
+
if not isinstance(paths, list) or not paths:
|
|
219
|
+
errors.append(f"R3 {where}: measure 必须是非空路径列表")
|
|
220
|
+
for path in paths if isinstance(paths, list) else []:
|
|
221
|
+
if not _anchor_ok(path, measurements, derived):
|
|
222
|
+
errors.append(f"R3 {where}: 测量路径 {path!r} 无法解析到 shots/loudness/derived 下的测量值")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _target_ok(path, value):
|
|
226
|
+
if not isinstance(path, str) or not is_resolved(value) or value is None:
|
|
227
|
+
return False
|
|
228
|
+
parts = path.split(".")
|
|
229
|
+
if parts[0] not in TARGET_ROOTS or any(p.isdigit() for p in parts) or path == "derived.first_original_at.s":
|
|
230
|
+
return False
|
|
231
|
+
return _num(value) or _TARGET_OBJECTS.fullmatch(path) is not None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _check_methods(methods, fact_ids, measurements, derived, errors):
|
|
235
|
+
"""R1 + R4 + R5: closed method shape, real evidence, targets only name measurement paths."""
|
|
236
|
+
for method in methods:
|
|
237
|
+
where = f"methods.{method.get('id')}"
|
|
238
|
+
if method.get("dimension") not in DIMENSIONS:
|
|
239
|
+
errors.append(f"R1 {where}: dimension 必须是 {'|'.join(DIMENSIONS)}")
|
|
240
|
+
if method.get("applies_to") not in APPLIES_TO:
|
|
241
|
+
errors.append(f"R1 {where}: applies_to 必须是 {'|'.join(APPLIES_TO)}")
|
|
242
|
+
for field in METHOD_TEXT_FIELDS:
|
|
243
|
+
if field in method and (not isinstance(method[field], str) or not method[field].strip()):
|
|
244
|
+
errors.append(f"R1 {where}: {field} 必须是非空字符串")
|
|
245
|
+
evidence = method.get("evidence")
|
|
246
|
+
if not isinstance(evidence, list) or not evidence:
|
|
247
|
+
errors.append(f"R4 {where}: evidence 至少一条")
|
|
248
|
+
for item in evidence if isinstance(evidence, list) else []:
|
|
249
|
+
ok = item in fact_ids or (
|
|
250
|
+
isinstance(item, str) and item.startswith("measure:")
|
|
251
|
+
and _anchor_ok(item[len("measure:"):], measurements, derived))
|
|
252
|
+
if not ok:
|
|
253
|
+
errors.append(f"R4 {where}: evidence {item!r} 既不是已有 fact id,也不是可解析的 measure:路径")
|
|
254
|
+
targets = method.get("targets", {})
|
|
255
|
+
if not isinstance(targets, dict):
|
|
256
|
+
errors.append(f"R1 {where}: targets 必须是对象")
|
|
257
|
+
continue
|
|
258
|
+
for name, target in targets.items():
|
|
259
|
+
twhere = f"{where}.targets.{name}"
|
|
260
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", str(name)):
|
|
261
|
+
errors.append(f"R1 {twhere}: 名字只用小写字母、数字和下划线")
|
|
262
|
+
if not _closed(target, "target", twhere, errors):
|
|
263
|
+
continue
|
|
264
|
+
if set(target) & {"value", "provenance"}:
|
|
265
|
+
errors.append(f"R5 {twhere}: 数值与 provenance 由 export 填写,只写 from")
|
|
266
|
+
path = target.get("from")
|
|
267
|
+
if "from" in target and not _target_ok(path, resolve(path, measurements, derived)):
|
|
268
|
+
errors.append(f"R5 {twhere}: from {path!r} 必须是 shots/loudness/derived 下的数值叶子或允许的派生对象")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _ngrams(text, size):
|
|
272
|
+
"""CJK n-grams per punctuation-free run, plus over all CJK joined (so「你可知道,我是…」still matches)."""
|
|
273
|
+
runs = _CJK.findall(text)
|
|
274
|
+
grams = set()
|
|
275
|
+
for run in [*runs, "".join(runs)]:
|
|
276
|
+
grams.update(run[i:i + size] for i in range(len(run) - size + 1))
|
|
277
|
+
return grams
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _word_grams(text, size):
|
|
281
|
+
words = [w.lower() for w in _WORD.findall(text)]
|
|
282
|
+
return {tuple(words[i:i + size]) for i in range(len(words) - size + 1)}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _mention_names(value):
|
|
286
|
+
"""Names one aliases/asr_mentions entry contributes.
|
|
287
|
+
|
|
288
|
+
The model writes plain strings; video-understanding's deterministic ASR/research fallback writes
|
|
289
|
+
{text, evidence_id, matched_aliases} dicts, whose names are the matched aliases (the text is the
|
|
290
|
+
ASR window, already in the source-text corpus). Anything else contributes nothing."""
|
|
291
|
+
if isinstance(value, str):
|
|
292
|
+
return [value]
|
|
293
|
+
if isinstance(value, dict):
|
|
294
|
+
matched = value.get("matched_aliases")
|
|
295
|
+
return [m for m in matched if isinstance(m, str)] if isinstance(matched, list) else []
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _index_names(index):
|
|
300
|
+
"""name / aliases / asr_mentions of characters, entities and research_glossary in understanding_index.json."""
|
|
301
|
+
names = set()
|
|
302
|
+
for key in ("characters", "entities", "research_glossary"):
|
|
303
|
+
items = index.get(key) if isinstance(index, dict) else None
|
|
304
|
+
for item in items if isinstance(items, list) else []:
|
|
305
|
+
if isinstance(item, dict):
|
|
306
|
+
if isinstance(item.get("name"), str):
|
|
307
|
+
names.add(item["name"])
|
|
308
|
+
for alias_key in ("aliases", "asr_mentions"):
|
|
309
|
+
values = item.get(alias_key)
|
|
310
|
+
for value in values if isinstance(values, list) else []:
|
|
311
|
+
names.update(_mention_names(value))
|
|
312
|
+
return names
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _strings(node):
|
|
316
|
+
if isinstance(node, str):
|
|
317
|
+
yield node
|
|
318
|
+
elif isinstance(node, dict):
|
|
319
|
+
for value in node.values():
|
|
320
|
+
yield from _strings(value)
|
|
321
|
+
elif isinstance(node, list):
|
|
322
|
+
for value in node:
|
|
323
|
+
yield from _strings(value)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _string_items(values):
|
|
327
|
+
"""The string entries of a JSON list; a non-list or a non-string entry contributes nothing."""
|
|
328
|
+
return [value for value in values if isinstance(value, str)] if isinstance(values, list) else []
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _research_names(research):
|
|
332
|
+
"""Character names and aliases, short cultural-note items, and short quoted terms.
|
|
333
|
+
|
|
334
|
+
background_research.json is agent-written: a name or alias that is not a string (a nested
|
|
335
|
+
object, a list) is ignored rather than fed to the name set."""
|
|
336
|
+
names = set()
|
|
337
|
+
if not isinstance(research, dict):
|
|
338
|
+
return names
|
|
339
|
+
characters = research.get("characters")
|
|
340
|
+
if isinstance(characters, dict):
|
|
341
|
+
names.update(characters)
|
|
342
|
+
elif isinstance(characters, list):
|
|
343
|
+
names.update(_string_items([c.get("name") for c in characters if isinstance(c, dict)]))
|
|
344
|
+
details = research.get("character_details")
|
|
345
|
+
for name, info in details.items() if isinstance(details, dict) else []:
|
|
346
|
+
names.add(name)
|
|
347
|
+
names.update(_string_items(info.get("aliases") if isinstance(info, dict) else None))
|
|
348
|
+
notes = research.get("cultural_notes")
|
|
349
|
+
for note in notes if isinstance(notes, list) else []:
|
|
350
|
+
item = note.get("item") if isinstance(note, dict) else None
|
|
351
|
+
if isinstance(item, str) and _CJK.fullmatch(item) and len(item) <= 5:
|
|
352
|
+
names.add(item)
|
|
353
|
+
for text in _strings(research):
|
|
354
|
+
names.update(g for match in _QUOTED.findall(text) for g in match if g)
|
|
355
|
+
return names
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _grams(texts):
|
|
359
|
+
return (set().union(*(_ngrams(t, CJK_RUN_MIN) for t in texts)) if texts else set(),
|
|
360
|
+
set().union(*(_word_grams(t, LATIN_RUN_MIN) for t in texts)) if texts else set())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def leak_corpus(facts, asr_segments, asr_evidence, research, index=None):
|
|
364
|
+
"""Entity names and source text that must never reach a transferable method.
|
|
365
|
+
|
|
366
|
+
Source text is the ASR (each window and each adjacent pair joined, so a line split by a window
|
|
367
|
+
boundary still matches) plus the background research prose; the agent's own fact statements
|
|
368
|
+
are kept apart so an error can say which side a shared phrase came from.
|
|
369
|
+
"""
|
|
370
|
+
names = {e for f in facts for e in (f.get("entities") or []) if isinstance(e, str)}
|
|
371
|
+
names.update(_index_names(index))
|
|
372
|
+
research = research if isinstance(research, dict) else {}
|
|
373
|
+
asr_evidence = asr_evidence if isinstance(asr_evidence, dict) else {}
|
|
374
|
+
names.update(_research_names(research))
|
|
375
|
+
glossary = asr_evidence.get("glossary")
|
|
376
|
+
names.update(_string_items(glossary.get("names") if isinstance(glossary, dict) else None))
|
|
377
|
+
windows = sorted((s for s in asr_segments or [] if isinstance(s, dict)),
|
|
378
|
+
key=lambda s: float(s.get("start") or 0))
|
|
379
|
+
texts = [str(s.get("text") or "") for s in windows]
|
|
380
|
+
texts += [a + b for a, b in zip(texts, texts[1:])]
|
|
381
|
+
texts += list(_strings(research))
|
|
382
|
+
cjk, latin = _grams(texts)
|
|
383
|
+
fact_cjk, fact_latin = _grams([str(f.get("statement") or "") for f in facts])
|
|
384
|
+
return {
|
|
385
|
+
"names": sorted({_SPACE.sub("", n) for n in names if isinstance(n, str) and len(_SPACE.sub("", n)) >= 2},
|
|
386
|
+
key=len, reverse=True),
|
|
387
|
+
"cjk": cjk, "latin": latin, "fact_cjk": fact_cjk - cjk, "fact_latin": fact_latin - latin,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def leak_errors(text, where, corpus, rule="R6"):
|
|
392
|
+
"""R6 a–d on one string; each error quotes the offending substring."""
|
|
393
|
+
errors = []
|
|
394
|
+
lowered = _SPACE.sub("", text).lower()
|
|
395
|
+
for name in corpus["names"]:
|
|
396
|
+
if name.lower() in lowered:
|
|
397
|
+
errors.append(f"{rule} {where}: 含原片实体名「{name}」")
|
|
398
|
+
cjk, words = _ngrams(text, CJK_RUN_MIN), _word_grams(text, LATIN_RUN_MIN)
|
|
399
|
+
for gram in sorted(cjk & corpus["cjk"])[:1]:
|
|
400
|
+
errors.append(f"{rule} {where}: 与原片台词/背景资料共有连续 {CJK_RUN_MIN}+ 字「{gram}」")
|
|
401
|
+
for gram in sorted(words & corpus["latin"])[:1]:
|
|
402
|
+
errors.append(f"{rule} {where}: 与原片台词/背景资料共有连续 {LATIN_RUN_MIN}+ 词「{' '.join(gram)}」")
|
|
403
|
+
for gram in sorted(cjk & corpus.get("fact_cjk", set()))[:1]:
|
|
404
|
+
errors.append(f"{rule} {where}: 与 source_facts 的描述共有连续 {CJK_RUN_MIN}+ 字「{gram}」"
|
|
405
|
+
"(若这是通用剪辑措辞,改写 fact 或方法任一侧)")
|
|
406
|
+
for gram in sorted(words & corpus.get("fact_latin", set()))[:1]:
|
|
407
|
+
errors.append(f"{rule} {where}: 与 source_facts 的描述共有连续 {LATIN_RUN_MIN}+ 词「{' '.join(gram)}」")
|
|
408
|
+
for match in _TIMECODE.finditer(text):
|
|
409
|
+
errors.append(f"{rule} {where}: 含绝对时间「{match.group(0)}」,改用相对位置或 targets")
|
|
410
|
+
for match in _ABS_PATH.finditer(text):
|
|
411
|
+
errors.append(f"{rule} {where}: 含绝对路径「{match.group(0)}」")
|
|
412
|
+
return errors
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _warnings(breakdown, methods, asr_segments, asr_status, research, *, index, asr_evidence, source,
|
|
416
|
+
review_windows=None):
|
|
417
|
+
warnings = []
|
|
418
|
+
for method in methods:
|
|
419
|
+
if not method.get("applies_when"):
|
|
420
|
+
warnings.append(f"methods.{method.get('id')}: 没写 applies_when,后续制作难判断何时套用")
|
|
421
|
+
if _NUMBER_IN_RULE.search(str(method.get("rule") or "")):
|
|
422
|
+
warnings.append(f"methods.{method.get('id')}: rule 正文含数字,建议把数值放进 targets")
|
|
423
|
+
if asr_status != "AVAILABLE_COARSE":
|
|
424
|
+
warnings.append(f"ASR 状态为 {asr_status},旁白语速与泄漏扫描的台词覆盖都不完整")
|
|
425
|
+
has_cjk = any(_CJK.search(str(s.get("text") or "")) for s in asr_segments if isinstance(s, dict))
|
|
426
|
+
if has_cjk and not ((_research_names(research) | _index_names(index)) - {None, ""}):
|
|
427
|
+
warnings.append("ASR 有中文对白,但 background_research.json 与 understanding_index.json 都没有给出名字:"
|
|
428
|
+
"泄漏扫描只认 fact entities 里写到的名字,台词里其他人名、地名、组织名会漏网")
|
|
429
|
+
asr_video = (asr_evidence if isinstance(asr_evidence, dict) else {}).get("source_video")
|
|
430
|
+
if isinstance(asr_video, dict) and {k: asr_video.get(k) for k in ("size", "mtime_ns")} != {
|
|
431
|
+
k: source.get(k) for k in ("size", "mtime_ns")}:
|
|
432
|
+
warnings.append("asr_timing_evidence.json 的 source_video 与测量的成片身份不一致:理解产物可能来自另一部视频,"
|
|
433
|
+
"语速与泄漏扫描会用错台词和人名")
|
|
434
|
+
labels = breakdown.get("labels") if isinstance(breakdown.get("labels"), dict) else {}
|
|
435
|
+
windows = review_windows or []
|
|
436
|
+
if windows and "cut_fixes" not in labels:
|
|
437
|
+
warnings.append(f"measure 压下了 {len(windows)} 个疑似切点窗口:用 `frames --review` 看图,再写 labels.cut_fixes"
|
|
438
|
+
"(看过且无需改动就写 {}),切点数与各段切点密度才算复核过")
|
|
439
|
+
if not str(labels.get("basis") or "").strip():
|
|
440
|
+
warnings.append("labels.basis 为空:写明标注依据(如 5s ASR 窗口 + 故事板)")
|
|
441
|
+
return warnings
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def check_breakdown(breakdown, measurements, asr_segments=(), asr_evidence=None, research=None, index=None):
|
|
445
|
+
"""Run R1–R7; return {derived, errors, warnings}."""
|
|
446
|
+
errors = []
|
|
447
|
+
duration = measurements["source"]["duration_s"]
|
|
448
|
+
if not _closed(breakdown, "top", "breakdown", errors):
|
|
449
|
+
return {"derived": None, "errors": errors, "warnings": []}
|
|
450
|
+
if breakdown.get("schema") != BREAKDOWN_SCHEMA:
|
|
451
|
+
errors.append(f"R1 breakdown.schema 必须是 {BREAKDOWN_SCHEMA}")
|
|
452
|
+
labels = breakdown.get("labels", {})
|
|
453
|
+
errors_before = len(errors)
|
|
454
|
+
spans, sections = _check_labels(labels, duration, errors, measurements["shots"]["cuts"])
|
|
455
|
+
fixes_ok = not any(e.startswith(("R1 labels.cut_fixes", "R2 labels.cut_fixes")) for e in errors[errors_before:])
|
|
456
|
+
measurements = apply_cut_fixes(measurements, labels) if fixes_ok and isinstance(labels, dict) else measurements
|
|
457
|
+
asr_segments = [s for s in (asr_segments or []) if isinstance(s, dict)]
|
|
458
|
+
asr_status = (asr_evidence if isinstance(asr_evidence, dict) else {}).get("status") or "UNKNOWN"
|
|
459
|
+
derived = derive({"audio_spans": spans, "sections": sections}, measurements, asr_segments, asr_status)
|
|
460
|
+
|
|
461
|
+
facts, methods = [], []
|
|
462
|
+
for key, kind, bucket in (("source_facts", "fact", facts), ("methods", "method", methods)):
|
|
463
|
+
items = breakdown.get(key, [])
|
|
464
|
+
if not isinstance(items, list):
|
|
465
|
+
errors.append(f"R1 breakdown.{key}: 必须是列表")
|
|
466
|
+
continue
|
|
467
|
+
bucket.extend(item for i, item in enumerate(items) if _closed(item, kind, f"{key}[{i}]", errors))
|
|
468
|
+
fact_ids = _check_ids(facts, "f", "source_facts", errors)
|
|
469
|
+
_check_ids(methods, "m", "methods", errors)
|
|
470
|
+
_check_facts(facts, measurements, derived, duration, errors)
|
|
471
|
+
_check_methods(methods, fact_ids, measurements, derived, errors)
|
|
472
|
+
|
|
473
|
+
corpus = leak_corpus(facts, asr_segments, asr_evidence, research, index)
|
|
474
|
+
for method in methods:
|
|
475
|
+
for field in METHOD_TEXT_FIELDS:
|
|
476
|
+
if isinstance(method.get(field), str):
|
|
477
|
+
errors.extend(leak_errors(method[field], f"methods.{method.get('id')}.{field}", corpus))
|
|
478
|
+
|
|
479
|
+
skipped = breakdown.get("skipped_dimensions", {})
|
|
480
|
+
if not isinstance(skipped, dict) or set(skipped) - set(DIMENSIONS):
|
|
481
|
+
errors.append(f"R1 skipped_dimensions: 键只能是 {'|'.join(DIMENSIONS)}")
|
|
482
|
+
skipped = {}
|
|
483
|
+
for dimension, reason in skipped.items():
|
|
484
|
+
if not (isinstance(reason, str) and reason.strip()):
|
|
485
|
+
errors.append(f"R1 skipped_dimensions.{dimension}: 原因必须是非空字符串")
|
|
486
|
+
else: # exported verbatim, so scanned now rather than only at export (R8)
|
|
487
|
+
errors.extend(leak_errors(reason, f"skipped_dimensions.{dimension}", corpus))
|
|
488
|
+
covered = {m.get("dimension") for m in methods}
|
|
489
|
+
for dimension in DIMENSIONS:
|
|
490
|
+
if dimension not in covered and dimension not in skipped:
|
|
491
|
+
errors.append(f"R7 {dimension}: 至少一条 method,或在 skipped_dimensions 写明原因")
|
|
492
|
+
warnings = _warnings(breakdown, methods, asr_segments, asr_status, research,
|
|
493
|
+
index=index, asr_evidence=asr_evidence, source=measurements["source"],
|
|
494
|
+
review_windows=measurements["shots"].get("review_windows"))
|
|
495
|
+
return {"derived": derived, "errors": errors, "warnings": warnings}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _load(work_dir):
|
|
499
|
+
work_dir = Path(work_dir)
|
|
500
|
+
measurements = read_json(work_dir / MEASUREMENTS_FILE)
|
|
501
|
+
breakdown = read_json(work_dir / BREAKDOWN_FILE)
|
|
502
|
+
missing = [name for name, value in ((MEASUREMENTS_FILE, measurements), (BREAKDOWN_FILE, breakdown)) if value is None]
|
|
503
|
+
return measurements, breakdown, {
|
|
504
|
+
"asr_segments": read_json(work_dir / "asr_result.json", []),
|
|
505
|
+
"asr_evidence": read_json(work_dir / "asr_timing_evidence.json"),
|
|
506
|
+
"research": read_json(work_dir / "background_research.json"),
|
|
507
|
+
"index": read_json(work_dir / "understanding_index.json"),
|
|
508
|
+
}, missing
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def run_check(work_dir):
|
|
512
|
+
measurements, breakdown, extra, missing = _load(work_dir)
|
|
513
|
+
if missing:
|
|
514
|
+
return {"derived": None, "errors": [f"缺少 {name}" for name in missing], "warnings": []}
|
|
515
|
+
return check_breakdown(breakdown, measurements, **extra)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _walk(node, where="$"):
|
|
519
|
+
if isinstance(node, dict):
|
|
520
|
+
for key, value in node.items():
|
|
521
|
+
yield "key", str(key), f"{where}.{key}"
|
|
522
|
+
yield from _walk(value, f"{where}.{key}")
|
|
523
|
+
elif isinstance(node, list):
|
|
524
|
+
for i, value in enumerate(node):
|
|
525
|
+
yield from _walk(value, f"{where}[{i}]")
|
|
526
|
+
elif isinstance(node, str):
|
|
527
|
+
yield "str", node, where
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def export_errors(production, corpus):
|
|
531
|
+
"""R8: re-scan every key and string of the serialized export and reject source-only keys."""
|
|
532
|
+
errors = []
|
|
533
|
+
for kind, text, where in _walk(json.loads(json.dumps(production, ensure_ascii=False))):
|
|
534
|
+
if kind == "key" and text in BANNED_EXPORT_KEYS:
|
|
535
|
+
errors.append(f"R8 {where}: 导出物不能含键 {text!r}")
|
|
536
|
+
errors.extend(leak_errors(text, where, corpus, rule="R8"))
|
|
537
|
+
return errors
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def run_export(work_dir, out_path):
|
|
541
|
+
"""Check, project, re-scan, then write; returns the report (with `written` on success)."""
|
|
542
|
+
report = run_check(work_dir)
|
|
543
|
+
if report["errors"]:
|
|
544
|
+
return report
|
|
545
|
+
measurements, breakdown, extra, _ = _load(work_dir)
|
|
546
|
+
measurements = apply_cut_fixes(measurements, breakdown.get("labels"))
|
|
547
|
+
production = build_production(breakdown, measurements, report["derived"])
|
|
548
|
+
facts = [f for f in breakdown.get("source_facts") or [] if isinstance(f, dict)]
|
|
549
|
+
corpus = leak_corpus(facts, extra["asr_segments"], extra["asr_evidence"], extra["research"], extra["index"])
|
|
550
|
+
report["errors"] = export_errors(production, corpus)
|
|
551
|
+
if not report["errors"]:
|
|
552
|
+
report["written"] = str(write_json(out_path, production))
|
|
553
|
+
return report
|