dsh-drawai 0.3.4 → 0.4.0
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/LICENSE +4142 -0
- package/README.md +43 -10
- package/lib/audio-media.js +147 -0
- package/lib/audio-service.js +66 -0
- package/lib/canvas-format.js +134 -0
- package/lib/client.js +5075 -181
- package/lib/comfyui-default-workflow.mjs +1825 -0
- package/lib/comfyui-workflow.mjs +426 -0
- package/lib/document-commands.js +12 -0
- package/lib/document-core.js +29 -0
- package/lib/drawing-tasks.js +26 -0
- package/lib/imagegen.mjs +596 -0
- package/lib/index.js +1758 -129
- package/lib/media.js +26 -13
- package/lib/mgstyle-core.js +75 -0
- package/lib/motion/apply.js +180 -0
- package/lib/motion/asset-pack-lock.js +10 -0
- package/lib/motion/asset-pack.js +335 -0
- package/lib/motion/assets.js +290 -0
- package/lib/motion/audio.js +103 -0
- package/lib/motion/audiolevel.js +63 -0
- package/lib/motion/audiomix.js +144 -0
- package/lib/motion/beats.js +337 -0
- package/lib/motion/builtin-assets.js +62 -0
- package/lib/motion/canvas-spec.js +442 -0
- package/lib/motion/capture.js +95 -0
- package/lib/motion/cards.js +80 -0
- package/lib/motion/collect.js +447 -0
- package/lib/motion/delivery.js +112 -0
- package/lib/motion/edge-speech.py +90 -0
- package/lib/motion/entry.jsx +73 -0
- package/lib/motion/export.js +200 -0
- package/lib/motion/ffmpeg.js +42 -0
- package/lib/motion/fonts.js +71 -0
- package/lib/motion/jobs.js +113 -0
- package/lib/motion/json.js +4 -0
- package/lib/motion/media-catalog.js +59 -0
- package/lib/motion/mgstyles/BRIEF-TEMPLATE.md +63 -0
- package/lib/motion/mgstyles/briefs/01-flat-vector.md +104 -0
- package/lib/motion/mgstyles/briefs/02-line-art.md +97 -0
- package/lib/motion/mgstyles/briefs/03-isometric.md +108 -0
- package/lib/motion/mgstyles/briefs/05-cel-boil.md +145 -0
- package/lib/motion/mgstyles/briefs/06-collage.md +149 -0
- package/lib/motion/mgstyles/briefs/07-liquid.md +106 -0
- package/lib/motion/mgstyles/briefs/08-morph.md +111 -0
- package/lib/motion/mgstyles/briefs/09-bauhaus.md +150 -0
- package/lib/motion/mgstyles/briefs/10-synthwave.md +151 -0
- package/lib/motion/mgstyles/briefs/12-aurora-glass.md +147 -0
- package/lib/motion/mgstyles/briefs/18-hanazi.md +140 -0
- package/lib/motion/mgstyles/briefs/19-paperclip.md +156 -0
- package/lib/motion/mgstyles/briefs/20-pixel.md +118 -0
- package/lib/motion/mgstyles/briefs/22-hud.md +118 -0
- package/lib/motion/mgstyles/catalog.d/01-flat-vector.json +34 -0
- package/lib/motion/mgstyles/catalog.d/02-line-art.json +34 -0
- package/lib/motion/mgstyles/catalog.d/03-isometric.json +35 -0
- package/lib/motion/mgstyles/catalog.d/05-cel-boil.json +28 -0
- package/lib/motion/mgstyles/catalog.d/06-collage.json +28 -0
- package/lib/motion/mgstyles/catalog.d/07-liquid.json +28 -0
- package/lib/motion/mgstyles/catalog.d/08-morph.json +28 -0
- package/lib/motion/mgstyles/catalog.d/09-bauhaus.json +28 -0
- package/lib/motion/mgstyles/catalog.d/10-synthwave.json +28 -0
- package/lib/motion/mgstyles/catalog.d/12-aurora-glass.json +28 -0
- package/lib/motion/mgstyles/catalog.d/18-hanazi.json +28 -0
- package/lib/motion/mgstyles/catalog.d/19-paperclip.json +28 -0
- package/lib/motion/mgstyles/catalog.d/20-pixel.json +28 -0
- package/lib/motion/mgstyles/catalog.d/22-hud.json +28 -0
- package/lib/motion/mgstyles/catalog.json +1356 -0
- package/lib/motion/mgstyles/upstream/README.md +147 -0
- package/lib/motion/mgstyles/upstream/README.zh-CN.md +147 -0
- package/lib/motion/mgstyles/upstream/demos/01-flat-vector/cues.json +1 -0
- package/lib/motion/mgstyles/upstream/demos/01-flat-vector/index.html +712 -0
- package/lib/motion/mgstyles/upstream/demos/02-line-art/cues.json +331 -0
- package/lib/motion/mgstyles/upstream/demos/02-line-art/film.js +676 -0
- package/lib/motion/mgstyles/upstream/demos/02-line-art/index.html +18 -0
- package/lib/motion/mgstyles/upstream/demos/02-line-art/tools/dump_timing.mjs +26 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/cues.json +109 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/index.html +25 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/anim.js +82 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/main.js +219 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/night5.js +180 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/render.js +202 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/title.js +167 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/title5.js +294 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/ui.js +141 -0
- package/lib/motion/mgstyles/upstream/demos/03-isometric/js/world.js +1399 -0
- package/lib/motion/mgstyles/upstream/demos/05-cel-boil/cues.json +157 -0
- package/lib/motion/mgstyles/upstream/demos/05-cel-boil/index.html +34 -0
- package/lib/motion/mgstyles/upstream/demos/05-cel-boil/js/engine.js +326 -0
- package/lib/motion/mgstyles/upstream/demos/05-cel-boil/js/scene.js +2036 -0
- package/lib/motion/mgstyles/upstream/demos/06-collage/assets/gen/layout.json +157 -0
- package/lib/motion/mgstyles/upstream/demos/06-collage/cues.json +80 -0
- package/lib/motion/mgstyles/upstream/demos/06-collage/index.html +889 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/cues.json +100 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/index.html +108 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/engine.js +153 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/links.js +64 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/lookdev.js +46 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/scene.js +963 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/shaders.js +647 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/textsdf.js +114 -0
- package/lib/motion/mgstyles/upstream/demos/07-liquid/js/type.js +50 -0
- package/lib/motion/mgstyles/upstream/demos/08-morph/cues.json +131 -0
- package/lib/motion/mgstyles/upstream/demos/08-morph/glyphs.json +1 -0
- package/lib/motion/mgstyles/upstream/demos/08-morph/index.html +901 -0
- package/lib/motion/mgstyles/upstream/demos/09-bauhaus/cues.json +270 -0
- package/lib/motion/mgstyles/upstream/demos/09-bauhaus/film.js +697 -0
- package/lib/motion/mgstyles/upstream/demos/09-bauhaus/index.html +12 -0
- package/lib/motion/mgstyles/upstream/demos/10-synthwave/cues.json +44 -0
- package/lib/motion/mgstyles/upstream/demos/10-synthwave/index.html +1028 -0
- package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/cues.json +67 -0
- package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/index.html +16 -0
- package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/main.js +967 -0
- package/lib/motion/mgstyles/upstream/demos/12-aurora-glass/shaders.js +468 -0
- package/lib/motion/mgstyles/upstream/demos/18-hanazi/cues.json +117 -0
- package/lib/motion/mgstyles/upstream/demos/18-hanazi/index.html +541 -0
- package/lib/motion/mgstyles/upstream/demos/19-paperclip/assets/countries-110m.json +1 -0
- package/lib/motion/mgstyles/upstream/demos/19-paperclip/assets/stickers/meta.json +34 -0
- package/lib/motion/mgstyles/upstream/demos/19-paperclip/cues.json +285 -0
- package/lib/motion/mgstyles/upstream/demos/19-paperclip/index.html +599 -0
- package/lib/motion/mgstyles/upstream/demos/20-pixel/cues.json +42 -0
- package/lib/motion/mgstyles/upstream/demos/20-pixel/film.js +914 -0
- package/lib/motion/mgstyles/upstream/demos/20-pixel/index.html +18 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/cues.json +108 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/index.html +125 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/js/holo.js +275 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/js/hud.js +855 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/js/post.js +150 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/js/util.js +65 -0
- package/lib/motion/mgstyles/upstream/demos/22-hud/js/v5.js +142 -0
- package/lib/motion/mgstyles/upstream/harness/render.mjs +319 -0
- package/lib/motion/mgstyles/upstream/manifest.json +269 -0
- package/lib/motion/mgstyles/upstream/prompts/01-flat-vector.md +98 -0
- package/lib/motion/mgstyles/upstream/prompts/02-line-art.md +95 -0
- package/lib/motion/mgstyles/upstream/prompts/03-isometric.md +97 -0
- package/lib/motion/mgstyles/upstream/prompts/05-cel-boil.md +95 -0
- package/lib/motion/mgstyles/upstream/prompts/06-collage.md +95 -0
- package/lib/motion/mgstyles/upstream/prompts/07-liquid.md +98 -0
- package/lib/motion/mgstyles/upstream/prompts/08-morph.md +95 -0
- package/lib/motion/mgstyles/upstream/prompts/09-bauhaus.md +96 -0
- package/lib/motion/mgstyles/upstream/prompts/10-synthwave.md +95 -0
- package/lib/motion/mgstyles/upstream/prompts/12-aurora-glass.md +96 -0
- package/lib/motion/mgstyles/upstream/prompts/18-hanazi.md +95 -0
- package/lib/motion/mgstyles/upstream/prompts/19-paperclip.md +96 -0
- package/lib/motion/mgstyles/upstream/prompts/20-pixel.md +96 -0
- package/lib/motion/mgstyles/upstream/prompts/22-hud.md +95 -0
- package/lib/motion/mgstyles/upstream/rubric.md +51 -0
- package/lib/motion/mgstyles/upstream/template.md +36 -0
- package/lib/motion/pixelqa.js +198 -0
- package/lib/motion/preview.js +95 -0
- package/lib/motion/project.js +395 -0
- package/lib/motion/qa.js +290 -0
- package/lib/motion/reference-media.jsx +10 -0
- package/lib/motion/render-settings.js +112 -0
- package/lib/motion/render.js +159 -0
- package/lib/motion/scene-kit.js +183 -0
- package/lib/motion/sfx.js +232 -0
- package/lib/motion/shotcraft/ClipCard.tsx +204 -0
- package/lib/motion/shotcraft/catalog.json +2461 -0
- package/lib/motion/shotcraft/upstream/SKILL.md +286 -0
- package/lib/motion/shotcraft/upstream/assets/lib/Caption.tsx +54 -0
- package/lib/motion/shotcraft/upstream/assets/lib/ClipCard.tsx +200 -0
- package/lib/motion/shotcraft/upstream/assets/lib/DigitRoll.tsx +45 -0
- package/lib/motion/shotcraft/upstream/assets/lib/FlashCut.tsx +20 -0
- package/lib/motion/shotcraft/upstream/assets/lib/FlatPanel.tsx +65 -0
- package/lib/motion/shotcraft/upstream/assets/lib/PageCam.tsx +151 -0
- package/lib/motion/shotcraft/upstream/assets/lib/VerticalTicker.tsx +129 -0
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/camera.tsx +75 -0
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/motion.ts +40 -0
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/rand.ts +11 -0
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/shake.ts +10 -0
- package/lib/motion/shotcraft/upstream/assets/scripts/__pycache__/sfx-source.cpython-313.pyc +0 -0
- package/lib/motion/shotcraft/upstream/assets/scripts/build-readme-cover.mjs +49 -0
- package/lib/motion/shotcraft/upstream/assets/scripts/capture-template.mjs +210 -0
- package/lib/motion/shotcraft/upstream/assets/scripts/sfx-source.py +423 -0
- package/lib/motion/shotcraft/upstream/assets/scripts/smoke-render-demos.py +170 -0
- package/lib/motion/shotcraft/upstream/assets/scripts/test_sfx_source.py +157 -0
- package/lib/motion/shotcraft/upstream/demos/README.md +120 -0
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Brand.tsx +112 -0
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Fixtures.tsx +647 -0
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Look.tsx +316 -0
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Motion.tsx +82 -0
- package/lib/motion/shotcraft/upstream/demos/_fixtures/PageCam2D.tsx +115 -0
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Polish.tsx +243 -0
- package/lib/motion/shotcraft/upstream/demos/_textures/live-layout.json +389 -0
- package/lib/motion/shotcraft/upstream/demos/camera/basic-3d-scene/Basic3DScene.tsx +250 -0
- package/lib/motion/shotcraft/upstream/demos/camera/crash-zoom-punch/CrashImpactReal.tsx +305 -0
- package/lib/motion/shotcraft/upstream/demos/camera/crash-zoom-punch/CrashZoomReal.tsx +246 -0
- package/lib/motion/shotcraft/upstream/demos/camera/cursor-flyover/CursorFlyover.tsx +390 -0
- package/lib/motion/shotcraft/upstream/demos/camera/depth-layer-moves/MultiplaneReal.tsx +138 -0
- package/lib/motion/shotcraft/upstream/demos/camera/graze-face-tour/GrazeFaceTour.tsx +542 -0
- package/lib/motion/shotcraft/upstream/demos/camera/overhead-camera-moves/OverheadTabletopDrop.tsx +315 -0
- package/lib/motion/shotcraft/upstream/demos/camera/overhead-camera-moves/TiltReveal.tsx +299 -0
- package/lib/motion/shotcraft/upstream/demos/camera/space-camera-moves/DroneDiveLanding.tsx +173 -0
- package/lib/motion/shotcraft/upstream/demos/camera/space-camera-moves/ExplodedView.tsx +348 -0
- package/lib/motion/shotcraft/upstream/demos/camera/steep-tilt-glide/SteepTiltGlide.tsx +366 -0
- package/lib/motion/shotcraft/upstream/demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx +279 -0
- package/lib/motion/shotcraft/upstream/demos/camera/tension-camera-moves/PullBackIsolation.tsx +184 -0
- package/lib/motion/shotcraft/upstream/demos/camera/terminal-3d/Terminal3D.tsx +390 -0
- package/lib/motion/shotcraft/upstream/demos/data/before-after-slider-scrub/BeforeAfterSliderScrub.tsx +155 -0
- package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/AxisRescaleShockV2.tsx +303 -0
- package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/OscilloscopeStreamV2.tsx +290 -0
- package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/UnitDotSwarmRegroupV2.tsx +278 -0
- package/lib/motion/shotcraft/upstream/demos/data/gauge-readout-moves/NeedleSweepSelftest.tsx +307 -0
- package/lib/motion/shotcraft/upstream/demos/data/gauge-readout-moves/TapeScrollFixedPointer.tsx +215 -0
- package/lib/motion/shotcraft/upstream/demos/data/scroll-brake-moves/BrakeReticleLock.tsx +299 -0
- package/lib/motion/shotcraft/upstream/demos/data/scroll-brake-moves/ChangelogScrollBrake.tsx +232 -0
- package/lib/motion/shotcraft/upstream/demos/data/timeline-travel/TimelineTravel.tsx +290 -0
- package/lib/motion/shotcraft/upstream/demos/effects/assemble-then-type-flyin/AssembleThenTypeFlyin.tsx +273 -0
- package/lib/motion/shotcraft/upstream/demos/effects/aurora-bloom-bg-flip/AuroraBloomBgFlip.tsx +167 -0
- package/lib/motion/shotcraft/upstream/demos/effects/brand-frame-snap/BrandFrameSnap.tsx +536 -0
- package/lib/motion/shotcraft/upstream/demos/effects/fui-hud-moves/ReticleLockOn.tsx +164 -0
- package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/FlylineArc.tsx +175 -0
- package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/GlowOrbAmbient.tsx +197 -0
- package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/OrbFlylineRelay.tsx +241 -0
- package/lib/motion/shotcraft/upstream/demos/effects/icon-performance-moves/AttentionBounce.tsx +279 -0
- package/lib/motion/shotcraft/upstream/demos/effects/impact-feedback/AnimeImpact.tsx +230 -0
- package/lib/motion/shotcraft/upstream/demos/effects/impact-feedback/HitCounter.tsx +345 -0
- package/lib/motion/shotcraft/upstream/demos/effects/light-play-moves/SheenSweepRetry.tsx +275 -0
- package/lib/motion/shotcraft/upstream/demos/effects/light-play-moves/SpotlightSweepReveal.tsx +229 -0
- package/lib/motion/shotcraft/upstream/demos/effects/radial-ripple-phone-chips/RadialRipplePhoneChips.tsx +312 -0
- package/lib/motion/shotcraft/upstream/demos/effects/riso-print-hits/RisoMisregistrationHit.tsx +222 -0
- package/lib/motion/shotcraft/upstream/demos/effects/scan-bracket-sweep/ScanBracketSweep.tsx +295 -0
- package/lib/motion/shotcraft/upstream/demos/effects/scanline-annotate-focus/ScanlineAnnotateFocus.tsx +329 -0
- package/lib/motion/shotcraft/upstream/demos/effects/scanline-assemble-flyin/ScanlineAssembleFlyin.tsx +315 -0
- package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/ImpactBurstKit.tsx +264 -0
- package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/KanadaPerspectiveSnap.tsx +260 -0
- package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/ScoreSlam.tsx +263 -0
- package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/CornerSpotlightReveal.tsx +123 -0
- package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/GlowWakeSleepPanel.tsx +187 -0
- package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/SlideSpotlightPan.tsx +175 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/autolayout-gap-dial/AutolayoutGapDial.tsx +305 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx +350 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx +352 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.tsx +218 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx +297 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/glass-pill-dictation-typing/GlassPillDictationTyping.tsx +157 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx +218 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/picker-carousel-feature-cycle/PickerCarouselFeatureCycle.tsx +202 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/segmented-thumb-hero/SegmentedThumbHero.tsx +265 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/theme-switch-moves/PaletteThemeRipple.tsx +457 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/theme-switch-moves/ThemeSweepToggle.tsx +446 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/type-and-filter/TypeAndFilter.tsx +249 -0
- package/lib/motion/shotcraft/upstream/demos/interaction/voice-waveform-live/VoiceWaveformLive.tsx +278 -0
- package/lib/motion/shotcraft/upstream/demos/opening/crane-rise-reveal/CraneRiseReveal.tsx +332 -0
- package/lib/motion/shotcraft/upstream/demos/opening/dataviz-landscape-open/DatavizLandscapeOpen.tsx +358 -0
- package/lib/motion/shotcraft/upstream/demos/opening/fracture/Fracture.tsx +201 -0
- package/lib/motion/shotcraft/upstream/demos/opening/icon-field-colorize/IconFieldColorize.tsx +200 -0
- package/lib/motion/shotcraft/upstream/demos/opening/letterspace-materialize/LetterspaceMaterialize.tsx +208 -0
- package/lib/motion/shotcraft/upstream/demos/opening/magician-card-flourish/MagicianCardFlourish.tsx +341 -0
- package/lib/motion/shotcraft/upstream/demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.tsx +389 -0
- package/lib/motion/shotcraft/upstream/demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx +293 -0
- package/lib/motion/shotcraft/upstream/demos/opening/stroke-segment-build/StrokeSegmentBuild.tsx +183 -0
- package/lib/motion/shotcraft/upstream/demos/opening/text-as-mask/TextAsMask.tsx +245 -0
- package/lib/motion/shotcraft/upstream/demos/outro/grain-dissolve/GrainDissolve.tsx +222 -0
- package/lib/motion/shotcraft/upstream/demos/outro/logo-shrink-wordmark-lockup/LogoShrinkWordmarkLockup.tsx +223 -0
- package/lib/motion/shotcraft/upstream/demos/outro/neon-triple-marquee/NeonTripleMarquee.tsx +217 -0
- package/lib/motion/shotcraft/upstream/demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx +343 -0
- package/lib/motion/shotcraft/upstream/demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx +357 -0
- package/lib/motion/shotcraft/upstream/demos/outro/ui-to-brand-morph/IconFlipBloomLogo.tsx +187 -0
- package/lib/motion/shotcraft/upstream/demos/outro/ui-to-brand-morph/InputMorphsIntoLogo.tsx +333 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/beat-cut-moves/PaparazziFlash.tsx +274 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/beat-step-list-theme-cycle/BeatStepListThemeCycle.tsx +270 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx +430 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/quad-split-parallel-scenes/QuadSplitParallelScenes.tsx +252 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/rhythm-interrupt-moves/JumpCutPunchIn.tsx +106 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/sakuga-timing-shift/SakugaTimingShift.tsx +288 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/speed-ramp-freeze/FreezeAnnotateReal.tsx +362 -0
- package/lib/motion/shotcraft/upstream/demos/rhythm/trailer-grammar-moves/TrailerBumper.tsx +168 -0
- package/lib/motion/shotcraft/upstream/demos/transition/bottom-push-stack-wipe/BottomPushStackWipe.tsx +310 -0
- package/lib/motion/shotcraft/upstream/demos/transition/card-flip-reveal/CardFlipReveal.tsx +261 -0
- package/lib/motion/shotcraft/upstream/demos/transition/card-flock-tumble/CardFlockTumble.tsx +459 -0
- package/lib/motion/shotcraft/upstream/demos/transition/circle-match-iris/CircleMatchIris.tsx +294 -0
- package/lib/motion/shotcraft/upstream/demos/transition/gradient-transition/GradientTransition.tsx +161 -0
- package/lib/motion/shotcraft/upstream/demos/transition/line-carry-transition/LineCarryTransition.tsx +248 -0
- package/lib/motion/shotcraft/upstream/demos/transition/mosaic-reframe/MosaicReframe.tsx +359 -0
- package/lib/motion/shotcraft/upstream/demos/transition/page-turn-transitions/BarnDoorSplit.tsx +227 -0
- package/lib/motion/shotcraft/upstream/demos/transition/print-texture-transitions/InkBleedReveal.tsx +162 -0
- package/lib/motion/shotcraft/upstream/demos/transition/shot-transitions/PortalWipeV2.tsx +248 -0
- package/lib/motion/shotcraft/upstream/demos/transition/shot-transitions/WhipBrakeReal.tsx +186 -0
- package/lib/motion/shotcraft/upstream/demos/transition/tear-streak-transitions/GlitchDisplace.tsx +355 -0
- package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/InvisibleCut.tsx +272 -0
- package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/LightLeakBurn.tsx +262 -0
- package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/VersusSlam.tsx +217 -0
- package/lib/motion/shotcraft/upstream/demos/transition/transition-travel/LetterformZoom.tsx +285 -0
- package/lib/motion/shotcraft/upstream/demos/transition/white-flash-logo-simplify-cut/WhiteFlashLogoSimplifyCut.tsx +205 -0
- package/lib/motion/shotcraft/upstream/demos/transition/wipe-transitions/BlindsSlice.tsx +178 -0
- package/lib/motion/shotcraft/upstream/demos/typography/blur-slide/BlurSlide.tsx +147 -0
- package/lib/motion/shotcraft/upstream/demos/typography/brace-expand/BraceExpand.tsx +143 -0
- package/lib/motion/shotcraft/upstream/demos/typography/brand-ink-open/BrandInkOpen.tsx +134 -0
- package/lib/motion/shotcraft/upstream/demos/typography/countdown-arc-scatter/CountdownArcScatter.tsx +217 -0
- package/lib/motion/shotcraft/upstream/demos/typography/flying-words/FlyingWords.tsx +198 -0
- package/lib/motion/shotcraft/upstream/demos/typography/glitch-cycle/GlitchCycle.tsx +221 -0
- package/lib/motion/shotcraft/upstream/demos/typography/gradient-word-sweep/GradientWordSweep.tsx +312 -0
- package/lib/motion/shotcraft/upstream/demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx +261 -0
- package/lib/motion/shotcraft/upstream/demos/typography/marker-underline-title/MarkerUnderlineTitle.tsx +121 -0
- package/lib/motion/shotcraft/upstream/demos/typography/outline-word-fill/OutlineWordFill.tsx +153 -0
- package/lib/motion/shotcraft/upstream/demos/typography/paper-title-card/PaperTitleCard.tsx +179 -0
- package/lib/motion/shotcraft/upstream/demos/typography/pill-chip-slot-cycle-handled/PillChipSlotCycleHandled.tsx +140 -0
- package/lib/motion/shotcraft/upstream/demos/typography/pill-slot-cycle/PillSlotCycle.tsx +309 -0
- package/lib/motion/shotcraft/upstream/demos/typography/text-column-converge/TextColumnConverge.tsx +191 -0
- package/lib/motion/shotcraft/upstream/demos/typography/title-demote-to-label/TitleDemoteToLabel.tsx +292 -0
- package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/LetterformDriftAssembly.tsx +135 -0
- package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/SplitTextStagger.tsx +124 -0
- package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/TextOnPath.tsx +255 -0
- package/lib/motion/shotcraft/upstream/demos/typography/type-entrance-moves/LetterDropPhysics.tsx +173 -0
- package/lib/motion/shotcraft/upstream/demos/typography/type-rhythm-sync/FontWeightPump.tsx +225 -0
- package/lib/motion/shotcraft/upstream/demos/typography/type-rhythm-sync/KaraokeFillSync.tsx +141 -0
- package/lib/motion/shotcraft/upstream/demos/typography/typewriter-moves/TypewriterErrorRetype.tsx +137 -0
- package/lib/motion/shotcraft/upstream/demos/typography/vertical-word-roll-blur-cycle/VerticalWordRollBlurCycle.tsx +147 -0
- package/lib/motion/shotcraft/upstream/demos/typography/word-relay-filmstrip/WordRelayFilmstrip.tsx +409 -0
- package/lib/motion/shotcraft/upstream/demos/typography/word-relay-geometry/WordRelayGeometry.tsx +280 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/avatar-bracket-carousel/AvatarBracketCarousel.tsx +271 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/bezier-source-converge-merge/BezierSourceConvergeMerge.tsx +385 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/card-stack/CardStack.tsx +312 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/carousel-3d/Carousel3D.tsx +303 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/clipcard-looping/ClipCardLooping.tsx +77 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/cloner-depth-echo/ClonerDepthEcho.tsx +311 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/deck-deal-flyin/DeckDealFlyin.tsx +307 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/doc-park-left-pill-deal/DocParkLeftPillDeal.tsx +349 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/document-typewriter-reveal/DocumentTypewriterReveal.tsx +255 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/draw-svg-trace/DrawSvgTrace.tsx +244 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/element-body-moves/ContactShadowLift.tsx +217 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/integration-hub-map/IntegrationHubMap.tsx +652 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/list-reveal/ListReveal.tsx +153 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/list-stack-press/ListStackPress.tsx +238 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/neon-frame-forerun/NeonFrameForerun.tsx +324 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/neon-frame-orbit-drop/NeonFrameForerunOrbit.tsx +354 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/page-waterfall-wall/PageWaterfallWall.tsx +101 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/page-waterfall-wall/VerticalTicker.tsx +129 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.tsx +297 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/radial-wave/RadialWave.tsx +57 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/research-card-stack-scroll/ResearchCardStackScroll.tsx +155 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/row-embed/RowEmbed.tsx +175 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/value-stagger-gradient/ValueStaggerGradient.tsx +153 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/wall-reveal-moves/BentoLightUp.tsx +146 -0
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/wall-reveal-moves/WireframeDrawOn.tsx +226 -0
- package/lib/motion/shotcraft/upstream/gallery/api/library.json +1 -0
- package/lib/motion/shotcraft/upstream/manifest.json +2715 -0
- package/lib/motion/shotcraft/upstream/references/aesthetic-rules.md +193 -0
- package/lib/motion/shotcraft/upstream/references/final-review.md +121 -0
- package/lib/motion/shotcraft/upstream/references/guided-free-creation.md +199 -0
- package/lib/motion/shotcraft/upstream/references/jianying-export.md +183 -0
- package/lib/motion/shotcraft/upstream/references/music-beat-sync.md +257 -0
- package/lib/motion/shotcraft/upstream/references/pipeline.md +382 -0
- package/lib/motion/shotcraft/upstream/references/publish-upload-post.md +111 -0
- package/lib/motion/shotcraft/upstream/references/sequences/promo-energy-arc.md +50 -0
- package/lib/motion/shotcraft/upstream/references/sfx-sourcing.md +95 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/basic-3d-scene.md +41 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/crash-zoom-punch.md +52 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/cursor-flyover.md +39 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/depth-layer-moves.md +39 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/graze-face-tour.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/overhead-camera-moves.md +51 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/space-camera-moves.md +46 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/steep-tilt-glide.md +52 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/tension-camera-moves.md +43 -0
- package/lib/motion/shotcraft/upstream/references/shots/camera/terminal-3d.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/data/before-after-slider-scrub.md +48 -0
- package/lib/motion/shotcraft/upstream/references/shots/data/chart-live-moves.md +52 -0
- package/lib/motion/shotcraft/upstream/references/shots/data/gauge-readout-moves.md +45 -0
- package/lib/motion/shotcraft/upstream/references/shots/data/scroll-brake-moves.md +46 -0
- package/lib/motion/shotcraft/upstream/references/shots/data/timeline-travel.md +44 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/assemble-then-type-flyin.md +63 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/aurora-bloom-bg-flip.md +54 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/brand-frame-snap.md +58 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/fui-hud-moves.md +35 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/glow-flyline-moves.md +53 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/icon-performance-moves.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/impact-feedback.md +50 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/light-play-moves.md +46 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/radial-ripple-phone-chips.md +56 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/riso-print-hits.md +49 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/scan-bracket-sweep.md +50 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/scanline-annotate-focus.md +53 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/scanline-assemble-flyin.md +53 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/slam-entrance-moves.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/effects/spotlight-sweep-moves.md +52 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/autolayout-gap-dial.md +60 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/canvas-materialize-moves.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/chip-grid-single-select-blackout.md +51 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/collab-cursor-moves.md +41 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/glass-pill-dictation-typing.md +53 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/hashtag-to-pill-materialize.md +60 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/picker-carousel-feature-cycle.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/segmented-thumb-hero.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/theme-switch-moves.md +47 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/type-and-filter.md +37 -0
- package/lib/motion/shotcraft/upstream/references/shots/interaction/voice-waveform-live.md +54 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/brand-ink-open.md +36 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/crane-rise-reveal.md +56 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/dataviz-landscape-open.md +56 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/fracture.md +40 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/icon-field-colorize.md +60 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/letterspace-materialize.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/magician-card-flourish.md +73 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/orbit-ring-title-open.md +87 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/spotlight-hero-card.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/stroke-segment-build.md +50 -0
- package/lib/motion/shotcraft/upstream/references/shots/opening/text-as-mask.md +59 -0
- package/lib/motion/shotcraft/upstream/references/shots/outro/grain-dissolve.md +38 -0
- package/lib/motion/shotcraft/upstream/references/shots/outro/logo-shrink-wordmark-lockup.md +38 -0
- package/lib/motion/shotcraft/upstream/references/shots/outro/neon-triple-marquee.md +59 -0
- package/lib/motion/shotcraft/upstream/references/shots/outro/outro-group-photo-launch.md +39 -0
- package/lib/motion/shotcraft/upstream/references/shots/outro/ui-strip-away-outro.md +61 -0
- package/lib/motion/shotcraft/upstream/references/shots/outro/ui-to-brand-morph.md +52 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/beat-cut-moves.md +50 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/beat-step-list-theme-cycle.md +66 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/panel-grid-moves.md +43 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/quad-split-parallel-scenes.md +38 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/rhythm-interrupt-moves.md +38 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/sakuga-timing-shift.md +56 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/speed-ramp-freeze.md +44 -0
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/trailer-grammar-moves.md +38 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/bottom-push-stack-wipe.md +63 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/card-flip-reveal.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/card-flock-tumble.md +63 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/circle-match-iris.md +58 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/gradient-transition.md +45 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/line-carry-transition.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/mosaic-reframe.md +49 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/page-turn-transitions.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/print-texture-transitions.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/shot-transitions.md +45 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/tear-streak-transitions.md +48 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/transition-hidden-cut.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/transition-travel.md +43 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/white-flash-logo-simplify-cut.md +41 -0
- package/lib/motion/shotcraft/upstream/references/shots/transition/wipe-transitions.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/blur-slide.md +59 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/brace-expand.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/countdown-arc-scatter.md +65 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/document-typewriter-reveal.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/flying-words.md +64 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/glitch-cycle.md +64 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/gradient-word-sweep.md +54 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/lead-word-zoom-assemble.md +91 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/marker-underline-title.md +51 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/outline-word-fill.md +66 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/paper-title-card.md +38 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/pill-chip-slot-cycle-handled.md +66 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/pill-slot-cycle.md +58 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/text-column-converge.md +61 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/title-demote-to-label.md +64 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/type-assembly-moves.md +45 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/type-entrance-moves.md +41 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/type-rhythm-sync.md +51 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/typewriter-moves.md +42 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/vertical-word-roll-blur-cycle.md +63 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/word-relay-filmstrip.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/typography/word-relay-geometry.md +75 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/avatar-bracket-carousel.md +59 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/bezier-source-converge-merge.md +67 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/card-stack.md +60 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/carousel-3d.md +66 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/cloner-depth-echo.md +40 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/deck-deal-flyin.md +45 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/doc-park-left-pill-deal.md +62 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/draw-svg-trace.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/element-body-moves.md +37 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/integration-hub-map.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/list-reveal.md +50 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/list-stack-press.md +43 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/neon-frame-forerun.md +55 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/neon-frame-orbit-drop.md +51 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/page-waterfall-wall.md +51 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/product-card-progressive-assemble.md +56 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/radial-wave.md +49 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/research-card-stack-scroll.md +57 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/row-embed.md +36 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/value-stagger-gradient.md +64 -0
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/wall-reveal-moves.md +47 -0
- package/lib/motion/shotcraft/upstream/references/sound-design.md +170 -0
- package/lib/motion/shotcraft/upstream/references/workbench.md +157 -0
- package/lib/motion/shotcraft/upstream/template/TEMPLATE.md +103 -0
- package/lib/motion/shotcraft/upstream/template/THEMES.md +44 -0
- package/lib/motion/shotcraft/upstream/template/package.json +20 -0
- package/lib/motion/shotcraft/upstream/template/remotion.config.ts +6 -0
- package/lib/motion/shotcraft/upstream/template/src/Root.tsx +17 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/Caption.tsx +65 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/DigitRoll.tsx +48 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/FlashCut.tsx +24 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/Main.tsx +192 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/PaperTitleCard.tsx +111 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/PageCam.tsx +150 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneDetail.tsx +158 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneFlyIn.tsx +468 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneOpen.tsx +552 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneOutroLive.tsx +411 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/ScenePapers.tsx +200 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneWbr.tsx +324 -0
- package/lib/motion/shotcraft/upstream/template/src/aifl/live-layout.json +389 -0
- package/lib/motion/shotcraft/upstream/template/src/index.ts +4 -0
- package/lib/motion/shotcraft/upstream/template/src/themes/ThemedFilm.tsx +7 -0
- package/lib/motion/shotcraft/upstream/template/src/themes/manifest.tsx +68 -0
- package/lib/motion/shotcraft/upstream/template/src/themes/palette-assets.json +1 -0
- package/lib/motion/shotcraft/upstream/template/src/themes/palette-assets.ts +24 -0
- package/lib/motion/shotcraft/upstream/template/src/themes/palettes.json +74 -0
- package/lib/motion/shotcraft/upstream/template/src/themes/visual-theme.tsx +91 -0
- package/lib/motion/shotcraft/upstream/template/src/workbench.ts +213 -0
- package/lib/motion/shotcraft/upstream/template/tsconfig.json +15 -0
- package/lib/motion/spec.js +113 -0
- package/lib/motion/style-engine.js +153 -0
- package/lib/motion/style-frames.js +268 -0
- package/lib/motion/sync.js +214 -0
- package/lib/motion/theme.js +121 -0
- package/lib/motion/voice.js +417 -0
- package/lib/motion-agent-files.js +82 -0
- package/lib/motion-agent-protocol.js +66 -0
- package/lib/motion-agent-tools.js +196 -0
- package/lib/motion-agent.js +171 -0
- package/lib/motion-capture.js +92 -0
- package/lib/motion-core.js +238 -0
- package/lib/motion-film-review.js +71 -0
- package/lib/motion-library.js +210 -0
- package/lib/motion-planner.js +254 -0
- package/lib/motion-project.js +80 -0
- package/lib/motion-render-worker.js +145 -0
- package/lib/motion-session.js +32 -0
- package/lib/motion-source.js +49 -0
- package/lib/motion-tasks.js +452 -0
- package/lib/motion-vision.js +43 -0
- package/lib/mxfile.js +27 -7
- package/lib/routing/node-runtime.js +14 -0
- package/lib/routing/router.js +211 -0
- package/lib/routing/vendor/README.md +96 -0
- package/lib/routing/vendor/libavoid-routing.js +753 -0
- package/lib/routing/vendor/libavoid.min.js +59 -0
- package/lib/shotcard-core.js +39 -0
- package/lib/shotcard-reference.js +56 -0
- package/lib/style-kernel.js +37 -3
- package/lib/task-patches.js +26 -2
- package/lib/video-export.js +17 -2
- package/package.json +29 -4
package/lib/index.js
CHANGED
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
* 注册的工具:
|
|
13
13
|
* - diagram_read 读回画布文档(节点/边/形状/配色)
|
|
14
14
|
* - diagram_apply 施加结构化编辑 → 分层自动布局 → 原子写回
|
|
15
|
+
* - motion_task 主会话查询、修改和管理 Shotcraft 视频任务
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
19
|
+
import { parseFragment as parseHtmlFragment, serialize as serializeHtml } from 'parse5'
|
|
20
|
+
import postcss from 'postcss'
|
|
21
|
+
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises'
|
|
22
|
+
import { createReadStream, existsSync, readFileSync, rmSync, writeFileSync, appendFileSync, renameSync, realpathSync, mkdirSync, cpSync, lstatSync } from 'node:fs'
|
|
20
23
|
import { randomUUID, createHash } from 'node:crypto'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
21
25
|
import { createDocumentCoordinator, createMediaWorkSlots } from './task-coordinator.js'
|
|
22
26
|
import { createDrawingTaskManager } from './drawing-tasks.js'
|
|
23
27
|
import { applyDrawingResult } from './drawing-results.js'
|
|
@@ -27,7 +31,48 @@ import { createVideoTracePool } from './video-trace-pool.js'
|
|
|
27
31
|
import { videoTraceOptions, exactVideoFrameKey } from './video-trace-reuse.js'
|
|
28
32
|
import { createPerformanceLog } from './performance-log.js'
|
|
29
33
|
import { createVideoExportManager } from './video-export.js'
|
|
30
|
-
import {
|
|
34
|
+
import { contentHash } from './mxfile.js'
|
|
35
|
+
import { parseCanvas as parseMxfile, applyCanvas as applyDocToMxfile, buildCanvas as buildMxfile, isCanvasPath, isNativeCanvasPath, nativeCanvasPath, migrateCanvasFile, exportCanvasToMxfile, migrateMxfile, forkNativeCanvas } from './canvas-format.js'
|
|
36
|
+
import { emptyDocument, normalizeDocument } from './document-core.js'
|
|
37
|
+
import { createDocumentCommands } from './document-commands.js'
|
|
38
|
+
import { createOrthogonalRouter } from './routing/router.js'
|
|
39
|
+
import { getRoutingRuntime } from './routing/node-runtime.js'
|
|
40
|
+
import { motionInput, motionInputKey, assertMotionEditable, applyMotionResult, isMotionNode, motionNodeData, motionSettings, mergeMotionSettings, isAudioNode, audioNodeData, audioNodeSettings, motionReferenceEdges, motionReferenceRole, unlinkedShotCards } from './motion-core.js'
|
|
41
|
+
import { createAudioService } from './audio-service.js'
|
|
42
|
+
import { frozenAudioFile, importAudioFile } from './audio-media.js'
|
|
43
|
+
import { shotCardCatalog, enforceMotionCards, motionCardContract, mgStyleCatalog, mgStyleKit, mgStyleSources, mgStyleFile, mgStyleFileSize } from './motion-library.js'
|
|
44
|
+
import { searchShotCards, resolveShotStyle, shotCardData, isShotCardNode } from './shotcard-core.js'
|
|
45
|
+
import { searchMgStyles, resolveMgStyle, mgStyleData, isMgStyleNode } from './mgstyle-core.js'
|
|
46
|
+
import { captureStyleFrames } from './motion/style-frames.js'
|
|
47
|
+
import { assertEngineFidelity, assertQualityEvidence, ENGINE_LABEL, GATED_ENGINES } from './motion/style-engine.js'
|
|
48
|
+
import { prepareReferenceFrames } from './shotcard-reference.js'
|
|
49
|
+
import { inspectMotionFilm,motionSequenceReviewer } from './motion-film-review.js'
|
|
50
|
+
import { createMotionTaskManager } from './motion-tasks.js'
|
|
51
|
+
import { readSpec, validateSpec } from './motion/spec.js'
|
|
52
|
+
import { createProject, readTimeline, writeTimeline, listFiles, registerProject } from './motion/project.js'
|
|
53
|
+
import { renderProject, outputPath, outputInfo } from './motion/render.js'
|
|
54
|
+
import { describeRenderSettings } from './motion/render-settings.js'
|
|
55
|
+
import { previewProject } from './motion/preview.js'
|
|
56
|
+
import { RenderJobs, applyJobKind } from './motion/jobs.js'
|
|
57
|
+
import { assetsDirectory, captureAsset, cropAsset, fetchAsset, listAssets, colorsOf, probeAudio } from './motion/assets.js'
|
|
58
|
+
import { listMediaLibrary, importMediaLibrary } from './motion/media-catalog.js'
|
|
59
|
+
import { assetsSummary, ensureAssets, requireAssets } from './motion/asset-pack.js'
|
|
60
|
+
import { parseProjectJson } from './motion/json.js'
|
|
61
|
+
import { analyzeBeats, snapFrame } from './motion/beats.js'
|
|
62
|
+
import { resolveSync, verifySync, assetPath, filmHash } from './motion/sync.js'
|
|
63
|
+
import { addCue, cuePinnedAt, describeTrack, readTrack, removeCue } from './motion/audio.js'
|
|
64
|
+
import { applyFilm, extractAudio } from './motion/apply.js'
|
|
65
|
+
import { readCanvasSpec, validateCanvasSpec, scaffoldOps, findSpecNodes, storyboardTable, styleTemplate, SPEC_MARKERS } from './motion/canvas-spec.js'
|
|
66
|
+
import { capturePage } from './motion/capture.js'
|
|
67
|
+
import { collectPageAssets, gatherAssets } from './motion/collect.js'
|
|
68
|
+
import { getSfx, listSfx, searchSfx, readManifest as readAudioManifest } from './motion/sfx.js'
|
|
69
|
+
import { cueLevel, describeLevel, resolveGainDb, suggestedGainOf } from './motion/audiolevel.js'
|
|
70
|
+
import { describeMix } from './motion/audiomix.js'
|
|
71
|
+
import { runFrameQa, readQaReport, qaDir } from './motion/qa.js'
|
|
72
|
+
import { exportPackage, listExports } from './motion/export.js'
|
|
73
|
+
import { listThemes, resolveTheme, writeTheme, readTheme } from './motion/theme.js'
|
|
74
|
+
import { VOICE_CHOICES, VOICE_DEFAULTS, alignNarration, checkNarration, describeNarration, edgeAvailable, parseScript, planNarration, readScript, sayNarration, totalSpeechSeconds } from './motion/voice.js'
|
|
75
|
+
import { motionSessionNotice } from './motion-session.js'
|
|
31
76
|
import { decodeRaster } from './raster.js'
|
|
32
77
|
import {
|
|
33
78
|
TRACE_DEFAULTS,
|
|
@@ -82,6 +127,8 @@ import {
|
|
|
82
127
|
SVG_IMAGE_VALUE_PREFIX_B64,
|
|
83
128
|
arrowFromStyle,
|
|
84
129
|
avoidFromStyle,
|
|
130
|
+
routingModeOf,
|
|
131
|
+
styleWithRoutingMode,
|
|
85
132
|
colorNameFromStyle,
|
|
86
133
|
colorsFromStyle,
|
|
87
134
|
dashFromStyle,
|
|
@@ -109,6 +156,7 @@ import {
|
|
|
109
156
|
styleWithNodeShape,
|
|
110
157
|
styleWithSide,
|
|
111
158
|
styleWithSvgMarkup,
|
|
159
|
+
styleWithMediaMarkup,
|
|
112
160
|
styleWithTextColorName,
|
|
113
161
|
styleWithoutSvgMarkup,
|
|
114
162
|
svgMarkupFromImageValue,
|
|
@@ -137,12 +185,13 @@ function toAbsolute(p, cwd) {
|
|
|
137
185
|
return joinPath(cwd === undefined || cwd === null || cwd.length === 0 ? process.cwd() : cwd, s)
|
|
138
186
|
}
|
|
139
187
|
|
|
140
|
-
const DEFAULT_PATH = 'demo.
|
|
188
|
+
const DEFAULT_PATH = 'demo.drawai'
|
|
141
189
|
const DEFAULT_W = 170
|
|
142
190
|
/** 缺省高度取**整格**(10px 的倍数):否则节点中心会落在半像素上,连线容易出现"差一像素"的台阶。 */
|
|
143
191
|
const DEFAULT_H = 60
|
|
144
192
|
const GAP_PRIMARY = 90
|
|
145
193
|
const GAP_CROSS = 40
|
|
194
|
+
const documentRouter = createOrthogonalRouter(getRoutingRuntime)
|
|
146
195
|
const LAYOUTS = ['dagre-lr', 'dagre-tb', 'grid', 'none']
|
|
147
196
|
/** `order` 的四种去向(与画布右键的顺序菜单同一套:后面的在上面)。 */
|
|
148
197
|
const ORDER_MODES = ['front', 'back', 'up', 'down']
|
|
@@ -255,7 +304,22 @@ function edgePointFromOp(value, where) {
|
|
|
255
304
|
return { x: x, y: y }
|
|
256
305
|
}
|
|
257
306
|
|
|
258
|
-
/**
|
|
307
|
+
/** 精确折点严格校验;不做取整、吸附或静默过滤。 */
|
|
308
|
+
function edgeWaypointsFromOp(value, where) {
|
|
309
|
+
if (!Array.isArray(value) || value.length > 1000) throw new Error(where + ' points must be an array of at most 1000 {x, y} points')
|
|
310
|
+
return value.map((point, index) => {
|
|
311
|
+
if (!point || typeof point.x !== 'number' || typeof point.y !== 'number' ||
|
|
312
|
+
!Number.isFinite(point.x) || !Number.isFinite(point.y)) throw new Error(where + ' points[' + index + '] needs finite numeric x and y')
|
|
313
|
+
return { x: point.x, y: point.y }
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setExplicitWaypoints(edge, value, where) {
|
|
318
|
+
edge.points = edgeWaypointsFromOp(value, where)
|
|
319
|
+
edge.style = stylePatch(styleWithRoutingMode(edge.style || DEFAULT_EDGE_STYLE, 'manual'), { drawaiRouteStatus: null })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 扫描 `<prefix><n>` 形式的 id,返回 max+1。 */
|
|
259
323
|
function nextId(list, prefix) {
|
|
260
324
|
let max = 0
|
|
261
325
|
const re = new RegExp('^' + prefix + '(\\d+)$')
|
|
@@ -269,45 +333,8 @@ function nextId(list, prefix) {
|
|
|
269
333
|
return prefix + (max + 1)
|
|
270
334
|
}
|
|
271
335
|
|
|
272
|
-
function emptyDoc() {
|
|
273
|
-
|
|
274
|
-
// 空文档的指纹就是空串,第一次落盘后由写入器算出来。
|
|
275
|
-
//
|
|
276
|
-
// 带一个缺省图层:落盘时写出来的就是 `<mxCell id="1" parent="0" />`(与 drawio 一致,
|
|
277
|
-
// 字节不变),而**内存里的形状与"落盘后再打开"一致** —— 否则新建出来的画布在图层面板里
|
|
278
|
-
// 会显示"没有图层信息",一存一开又有了。
|
|
279
|
-
return {
|
|
280
|
-
version: 2,
|
|
281
|
-
revision: '',
|
|
282
|
-
meta: { layout: 'dagre-tb' },
|
|
283
|
-
nodes: [],
|
|
284
|
-
edges: [],
|
|
285
|
-
layers: [{ id: '1', name: '', visible: true, locked: false }],
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* 读入归一化。
|
|
291
|
-
*
|
|
292
|
-
* 文档层只认 v2(drawio style 键);归一化逻辑在 style-kernel 的 `normalizeDrawioDoc`,
|
|
293
|
-
* 两侧半边共用同一份,所以"盘上写什么、两边读到什么"不会分叉。
|
|
294
|
-
*/
|
|
295
|
-
function normalizeDoc(raw) {
|
|
296
|
-
const doc = normalizeDrawioDoc(raw)
|
|
297
|
-
return {
|
|
298
|
-
version: doc.version,
|
|
299
|
-
revision: doc.revision,
|
|
300
|
-
meta: doc.meta,
|
|
301
|
-
nodes: doc.nodes,
|
|
302
|
-
edges: doc.edges,
|
|
303
|
-
labels: doc.labels,
|
|
304
|
-
// 图层必须跟着走:丢掉它 = 保存时把单元挂回缺省层(用户的层就没了),
|
|
305
|
-
// 而且 AI 读不到"哪一层是隐藏的"。
|
|
306
|
-
layers: doc.layers,
|
|
307
|
-
// 纸张尺寸同理(丢了就悄悄变回 A4 缺省,谁都不会发现)。
|
|
308
|
-
...(doc.page !== undefined ? { page: doc.page } : {}),
|
|
309
|
-
}
|
|
310
|
-
}
|
|
336
|
+
function emptyDoc() { return emptyDocument() }
|
|
337
|
+
function normalizeDoc(raw) { return normalizeDocument(raw) }
|
|
311
338
|
|
|
312
339
|
/** 节点位置快照,用于判断一次布局到底动没动端点。 */
|
|
313
340
|
function nodePositions(doc) {
|
|
@@ -320,12 +347,11 @@ function nodePositions(doc) {
|
|
|
320
347
|
}
|
|
321
348
|
|
|
322
349
|
/**
|
|
323
|
-
*
|
|
350
|
+
* 布局改变端点后,只失效引擎拥有的自动路线。
|
|
324
351
|
*
|
|
325
352
|
* 这是 v1 的一个真实缺口:宿主半边当时**完全不知道 `points` 存在**(零读写),
|
|
326
353
|
* 于是 AI 一重排,盘上就留下一堆飘在旧位置上的折点,而没有任何清理或标记。
|
|
327
|
-
*
|
|
328
|
-
* 与 drawio 的自动路由在几何变化后重算路径同理。
|
|
354
|
+
* 自动折点属于路由缓存;手动折点和悬空端落点始终属于用户,不在此处清除。
|
|
329
355
|
*
|
|
330
356
|
* @returns 被清理的边数
|
|
331
357
|
*/
|
|
@@ -334,6 +360,7 @@ function invalidateStalePoints(doc, before) {
|
|
|
334
360
|
let dropped = 0
|
|
335
361
|
for (let i = 0; i < doc.edges.length; i += 1) {
|
|
336
362
|
const edge = doc.edges[i]
|
|
363
|
+
if (routingModeOf(edge) !== 'auto') continue
|
|
337
364
|
if (edge.points === undefined && edge.sourcePoint === undefined && edge.targetPoint === undefined) continue
|
|
338
365
|
const moved = function (id) {
|
|
339
366
|
const prev = before[id]
|
|
@@ -343,8 +370,7 @@ function invalidateStalePoints(doc, before) {
|
|
|
343
370
|
}
|
|
344
371
|
if (!moved(edge.from) && !moved(edge.to)) continue
|
|
345
372
|
delete edge.points
|
|
346
|
-
|
|
347
|
-
delete edge.targetPoint
|
|
373
|
+
edge.style = stylePatch(edge.style || '', { drawaiRouteStatus: null })
|
|
348
374
|
dropped += 1
|
|
349
375
|
}
|
|
350
376
|
return dropped
|
|
@@ -616,6 +642,50 @@ function nodeStyleFromOp(style, op, where) {
|
|
|
616
642
|
return out
|
|
617
643
|
}
|
|
618
644
|
|
|
645
|
+
/** Remove only the inline properties overridden by a whole-node text change. */
|
|
646
|
+
export function clearRichTextOverrides(html, options = {}) {
|
|
647
|
+
if (typeof html !== 'string' || (!options.color && !options.size)) return html
|
|
648
|
+
const fragment = parseHtmlFragment(html)
|
|
649
|
+
let changed = false
|
|
650
|
+
const visit = node => {
|
|
651
|
+
if (Array.isArray(node.attrs)) {
|
|
652
|
+
if (node.tagName === 'font') {
|
|
653
|
+
const before = node.attrs.length
|
|
654
|
+
node.attrs = node.attrs.filter(attr => !(options.color && attr.name === 'color') && !(options.size && attr.name === 'size'))
|
|
655
|
+
changed ||= node.attrs.length !== before
|
|
656
|
+
}
|
|
657
|
+
const attr = node.attrs.find(item => item.name === 'style')
|
|
658
|
+
if (attr) {
|
|
659
|
+
let rule
|
|
660
|
+
try {
|
|
661
|
+
const parsed = postcss.parse('x{' + attr.value + '}')
|
|
662
|
+
if (parsed.nodes.length !== 1 || parsed.first.type !== 'rule' || parsed.first.nodes.some(item => item.type !== 'decl' && item.type !== 'comment')) throw new Error('unsupported style')
|
|
663
|
+
rule = parsed.first
|
|
664
|
+
} catch { throw new Error('无法安全修改富文本中的 CSS 样式,请先在画布中编辑文字') }
|
|
665
|
+
// CSS font shorthand also controls family/weight and cannot be dropped
|
|
666
|
+
// without changing unrelated formatting. Do not silently destroy it.
|
|
667
|
+
if (options.size && rule.nodes.some(item => item.type === 'decl' && item.prop.toLowerCase() === 'font')) {
|
|
668
|
+
throw new Error('无法安全统一含 font 简写的富文本字号,请先在画布中编辑文字')
|
|
669
|
+
}
|
|
670
|
+
let cssChanged = false
|
|
671
|
+
rule.walkDecls(decl => {
|
|
672
|
+
const name = decl.prop.toLowerCase()
|
|
673
|
+
if ((options.color && name === 'color') || (options.size && name === 'font-size')) { decl.remove(); cssChanged = true }
|
|
674
|
+
})
|
|
675
|
+
if (cssChanged) {
|
|
676
|
+
changed = true
|
|
677
|
+
attr.value = rule.nodes.map(item => item.toString()).join(';')
|
|
678
|
+
if (!attr.value.trim()) node.attrs = node.attrs.filter(item => item !== attr)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
for (const child of node.childNodes || []) visit(child)
|
|
683
|
+
if (node.content) visit(node.content)
|
|
684
|
+
}
|
|
685
|
+
visit(fragment)
|
|
686
|
+
return changed ? serializeHtml(fragment) : html
|
|
687
|
+
}
|
|
688
|
+
|
|
619
689
|
/**
|
|
620
690
|
* 连线的"曲线"要看得见,就得有一个中点。
|
|
621
691
|
*
|
|
@@ -766,7 +836,16 @@ function cropRaster(raster, box, where) {
|
|
|
766
836
|
* 施加 ops。语义校验在这里:任何指向不存在节点的边、任何未知 id,都直接抛错并列出已知节点,
|
|
767
837
|
* 于是失败发生在写盘之前 —— 不会画出半张烂图。
|
|
768
838
|
*/
|
|
839
|
+
const documentCommands = createDocumentCommands({ ops: (draft, command) => executeOps(draft, command.ops, command.out, command.opctx) })
|
|
840
|
+
|
|
769
841
|
async function applyOps(doc, ops, out, opctx) {
|
|
842
|
+
const transaction = await documentCommands.execute(doc, { type: 'ops', ops, out, opctx })
|
|
843
|
+
for (const key of Object.keys(doc)) delete doc[key]
|
|
844
|
+
Object.assign(doc, transaction.doc)
|
|
845
|
+
return transaction.result
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function executeOps(doc, ops, out, opctx) {
|
|
770
849
|
const notes = []
|
|
771
850
|
/**
|
|
772
851
|
* 本次调用里的**别名**(`addNode`/`addEdge` 的 `as`)。
|
|
@@ -1045,7 +1124,11 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1045
1124
|
if (id === undefined) id = nextId(doc.nodes, 'n')
|
|
1046
1125
|
// 别名:给这个名字之后就能在同一个 ops 数组里引用它(见 applyOps 开头的注释)。
|
|
1047
1126
|
registerAlias(op.as, id, 'ops[' + i + '] addNode')
|
|
1048
|
-
const
|
|
1127
|
+
const shotSelection = op.shape === 'shotcard' ? resolveShotStyle(shotCardCatalog, op.cardId, op.styleKey) : null
|
|
1128
|
+
if (shotSelection && !label) label = '镜头参考\n' + shotSelection.label
|
|
1129
|
+
const mgSelection = op.shape === 'mgstyle' ? resolveMgStyle(mgStyleCatalog, op.slug) : null
|
|
1130
|
+
if (mgSelection && !label) label = '整片风格\n' + mgSelection.name
|
|
1131
|
+
const style = op.shape === 'audio' ? 'rounded=1;fillColor=#e1f3ee;strokeColor=#5a9e8a;whiteSpace=wrap;fontSize=14;' : shotSelection ? 'rounded=1;arcSize=12;fillColor=#eeeafa;strokeColor=#9482dc;fontColor=#44336b;whiteSpace=wrap;fontSize=14;' : mgSelection ? 'rounded=1;arcSize=10;fillColor=#fdf6e3;strokeColor=#c9a227;fontColor=#5a4a1a;whiteSpace=wrap;fontSize=14;' : nodeStyleFromOp(DEFAULT_NODE_STYLE, op, 'ops[' + i + '] addNode')
|
|
1049
1132
|
const videoNode = op.shape === 'video'
|
|
1050
1133
|
const svgNode = videoNode === false && nodeShapeFromStyle(style) === 'svg'
|
|
1051
1134
|
const node = {
|
|
@@ -1056,10 +1139,24 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1056
1139
|
// svg 节点缺省是**正方形**:按标签文字估宽(estimateWidth)对图没有意义。
|
|
1057
1140
|
// 空 label 的装饰面板也请显式给 w/h,否则会落到最短标签估宽(130)。
|
|
1058
1141
|
// video 缺省 320×180(16:9 播放器框)。
|
|
1059
|
-
w: numberOr(op.w, videoNode ? DEFAULT_VIDEO_W : svgNode ? DEFAULT_SVG_W : estimateWidth(label)),
|
|
1060
|
-
h: numberOr(op.h, videoNode ? DEFAULT_VIDEO_H : svgNode ? DEFAULT_SVG_H : DEFAULT_H),
|
|
1142
|
+
w: numberOr(op.w, shotSelection ? 320 : mgSelection ? 300 : videoNode ? DEFAULT_VIDEO_W : svgNode ? DEFAULT_SVG_W : estimateWidth(label)),
|
|
1143
|
+
h: numberOr(op.h, shotSelection ? 210 : mgSelection ? 120 : videoNode ? DEFAULT_VIDEO_H : svgNode ? DEFAULT_SVG_H : DEFAULT_H),
|
|
1144
|
+
}
|
|
1145
|
+
if (shotSelection) node.data = shotCardData(shotCardCatalog, shotSelection.cardId, shotSelection.styleKey)
|
|
1146
|
+
if (mgSelection) node.data = mgStyleData(mgStyleCatalog, mgSelection.slug)
|
|
1147
|
+
if (op.shape === 'audio') {
|
|
1148
|
+
node.data = audioNodeData(op.audioRole || 'sfx')
|
|
1149
|
+
if (op.audioClip) {
|
|
1150
|
+
if (!opctx?.drawioPath) throw new Error('导入音频需要画布路径')
|
|
1151
|
+
const {manifest} = frozenAudioFile(opctx.drawioPath, op.audioClip)
|
|
1152
|
+
Object.assign(node.data, {audioClip:op.audioClip,audioName:manifest.name,audioDuration:String(manifest.duration),audioSha256:manifest.sha256})
|
|
1153
|
+
}
|
|
1154
|
+
if (!label) node.label = node.data.audioName || '音频'
|
|
1155
|
+
node.w = numberOr(op.w,280); node.h = numberOr(op.h,110)
|
|
1061
1156
|
}
|
|
1062
|
-
if (videoNode) {
|
|
1157
|
+
if (videoNode && !op.mediaClip && !op.mediaKind) {
|
|
1158
|
+
node.data = motionNodeData()
|
|
1159
|
+
} else if (videoNode) {
|
|
1063
1160
|
const existingClips = []
|
|
1064
1161
|
for (let vi = 0; vi < doc.nodes.length; vi += 1) {
|
|
1065
1162
|
const vm = videoMetaFromData(doc.nodes[vi].data)
|
|
@@ -1087,6 +1184,20 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1087
1184
|
})
|
|
1088
1185
|
}
|
|
1089
1186
|
}
|
|
1187
|
+
// Additional canvas data must survive both native JSON and draw.io XML.
|
|
1188
|
+
// Reject invalid XML attribute names and never overwrite built-in metadata.
|
|
1189
|
+
if (has(op, 'data')) {
|
|
1190
|
+
if (!op.data || typeof op.data !== 'object' || Array.isArray(op.data)) throw new Error('ops[' + i + '] addNode data must be an object of string values')
|
|
1191
|
+
const additions = {}
|
|
1192
|
+
for (const [key, value] of Object.entries(op.data)) {
|
|
1193
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key) || ['id', 'label', '__proto__', 'constructor', 'prototype'].includes(key) || typeof value !== 'string') {
|
|
1194
|
+
throw new Error('ops[' + i + '] addNode data requires valid, non-reserved names and string values')
|
|
1195
|
+
}
|
|
1196
|
+
if (node.data && has(node.data, key)) throw new Error('ops[' + i + '] addNode data cannot override built-in field "' + key + '"')
|
|
1197
|
+
additions[key] = value
|
|
1198
|
+
}
|
|
1199
|
+
node.data = Object.assign({}, node.data || {}, additions)
|
|
1200
|
+
}
|
|
1090
1201
|
if (typeof op.layer === 'string' && op.layer.length > 0) node.layer = resolveLayerRef(op.layer, 'ops[' + i + '] addNode layer')
|
|
1091
1202
|
// 位置:只有显式给了坐标才写入。缺省写 (0,0) 会让 placeMissing 以为"坐标齐全"而不补位,
|
|
1092
1203
|
// 新节点就会堆在原点压住别人。
|
|
@@ -1132,18 +1243,28 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1132
1243
|
if (typeof op.label === 'string' && op.label.length > 0) edge.label = op.label
|
|
1133
1244
|
// 建边时就能带上画法:AI 想表达"这是一条异步/可选依赖"时,
|
|
1134
1245
|
// 不该被迫先 addEdge 再补一次 setStyle(两次写盘、两次往返)。
|
|
1135
|
-
|
|
1246
|
+
// 工作流节点之间默认使用圆角正交线;调用方指定的线型仍有优先权。
|
|
1247
|
+
const defaultStyle = from !== undefined && to !== undefined ? styleWithLineKind(DEFAULT_EDGE_STYLE, 'rounded') : DEFAULT_EDGE_STYLE
|
|
1248
|
+
edge.style = edgeStyleFromOp(styleWithRoutingMode(defaultStyle, 'manual'), op, 'ops[' + i + '] addEdge')
|
|
1136
1249
|
// 线型是"语义 + 几何"两件事:直线不带折点(新边本来就没有);曲线要看得见就得有个中点
|
|
1137
1250
|
// (见 arcWaypointFor —— 两点直连的曲线在 drawio 里会退化成一条直线)。
|
|
1138
1251
|
if (has(op, 'line') && normalizeLineKind(op.line, 'ops[' + i + '] addEdge') === 'curved') {
|
|
1139
1252
|
const bow = arcWaypointFor(doc, edge)
|
|
1140
1253
|
if (bow !== null) edge.points = [bow]
|
|
1254
|
+
edge.style = styleWithRoutingMode(edge.style, 'manual')
|
|
1255
|
+
}
|
|
1256
|
+
if (has(op, 'points')) {
|
|
1257
|
+
if (op.avoid === true) throw new Error('addEdge: points and avoid:true conflict; use manual points or automatic routing')
|
|
1258
|
+
setExplicitWaypoints(edge, op.points, 'ops[' + i + '] addEdge')
|
|
1141
1259
|
}
|
|
1142
1260
|
doc.edges.push(edge)
|
|
1143
1261
|
recordCreated(id, 'edge', typeof edge.label === 'string' ? edge.label : undefined)
|
|
1144
1262
|
const head = from !== undefined ? from : '(free ' + fromPoint.x + ',' + fromPoint.y + ')'
|
|
1145
1263
|
const tail = to !== undefined ? to : '(free ' + toPoint.x + ',' + toPoint.y + ')'
|
|
1146
1264
|
notes.push('+ edge ' + id + ' ' + head + ' -> ' + tail + styleNote(edge.style))
|
|
1265
|
+
if (from !== undefined && to !== undefined && !has(op, 'points') && !Array.isArray(edge.points) && edgeStyleValueFromStyle(edge.style) !== 'none' && routingModeOf(edge) !== 'auto') {
|
|
1266
|
+
notes.push('~ 连线 ' + id + ' 尚未明确指定折点:读回节点坐标后自行规划通道,用 setEdge {id:"' + id + '",points:[...]} 写入;对齐且无需拐弯可传空数组。')
|
|
1267
|
+
}
|
|
1147
1268
|
continue
|
|
1148
1269
|
}
|
|
1149
1270
|
|
|
@@ -1361,7 +1482,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1361
1482
|
}
|
|
1362
1483
|
|
|
1363
1484
|
if (kind === 'setEdge') {
|
|
1364
|
-
//
|
|
1485
|
+
// 重接或编辑折点;未显式指定的几何与样式保持原样。
|
|
1365
1486
|
// id、标签、标签位置、折点、端点约束(exitX/entryX)都留着。
|
|
1366
1487
|
// 以前只能 remove + addEdge 重画,那会把这些全丢掉。
|
|
1367
1488
|
const ids = idList(op, i, 'setEdge')
|
|
@@ -1414,14 +1535,19 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1414
1535
|
edge[freeKey] = point
|
|
1415
1536
|
touched += 1
|
|
1416
1537
|
}
|
|
1417
|
-
if (
|
|
1538
|
+
if (has(op, 'points')) {
|
|
1539
|
+
if (op.avoid === true) throw new Error('setEdge: points and avoid:true conflict; use manual points or automatic routing')
|
|
1540
|
+
setExplicitWaypoints(edge, op.points, 'ops[' + i + '] setEdge')
|
|
1541
|
+
touched += 1
|
|
1542
|
+
}
|
|
1543
|
+
if (touched === 0) throw new Error('ops[' + i + '] setEdge needs "from"/"to", "fromPoint"/"toPoint", or "points"')
|
|
1418
1544
|
const head = typeof edge.from === 'string' ? edge.from : '(free ' + edge.sourcePoint.x + ',' + edge.sourcePoint.y + ')'
|
|
1419
1545
|
const tail = typeof edge.to === 'string' ? edge.to : '(free ' + edge.targetPoint.x + ',' + edge.targetPoint.y + ')'
|
|
1420
1546
|
const kept = []
|
|
1421
|
-
if (Array.isArray(edge.points) && edge.points.length > 0) kept.push(edge.points.length + ' 个折点')
|
|
1547
|
+
if (!has(op, 'points') && Array.isArray(edge.points) && edge.points.length > 0) kept.push(edge.points.length + ' 个折点')
|
|
1422
1548
|
if (typeof edge.label === 'string' && edge.label.length > 0) kept.push('标签')
|
|
1423
1549
|
recordChanged(id)
|
|
1424
|
-
notes.push('~ edge ' + id + '
|
|
1550
|
+
notes.push('~ edge ' + id + ' ' + head + ' -> ' + tail + (has(op, 'points') ? ',手动折点 ' + edge.points.length + ' 个' : '') + (kept.length > 0 ? '(' + kept.join('、') + '照旧)' : ''))
|
|
1425
1551
|
continue
|
|
1426
1552
|
}
|
|
1427
1553
|
|
|
@@ -1600,6 +1726,10 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1600
1726
|
const id = ids[k]
|
|
1601
1727
|
const ni = findNode(id)
|
|
1602
1728
|
if (ni >= 0) {
|
|
1729
|
+
if (doc.nodes[ni].label !== label) {
|
|
1730
|
+
delete doc.nodes[ni].labelHtml
|
|
1731
|
+
if (doc.nodes[ni].data) delete doc.nodes[ni].data.drawaiTableEscaped
|
|
1732
|
+
}
|
|
1603
1733
|
doc.nodes[ni].label = label
|
|
1604
1734
|
recordChanged(id)
|
|
1605
1735
|
notes.push('~ node ' + id + ' label = "' + label + '"')
|
|
@@ -1607,6 +1737,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1607
1737
|
}
|
|
1608
1738
|
const ei = findEdge(id)
|
|
1609
1739
|
if (ei >= 0) {
|
|
1740
|
+
if (doc.edges[ei].label !== label) delete doc.edges[ei].labelHtml
|
|
1610
1741
|
doc.edges[ei].label = label
|
|
1611
1742
|
recordChanged(id)
|
|
1612
1743
|
notes.push('~ edge ' + id + ' label = "' + label + '"')
|
|
@@ -1650,6 +1781,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1650
1781
|
// (mxPolyline.paintCurvedLine 两点时控制点落在起点上),所以补一个弓形中点。
|
|
1651
1782
|
if (has(op, 'line')) {
|
|
1652
1783
|
const kind = normalizeLineKind(op.line, where)
|
|
1784
|
+
if (kind === 'curved') edge.style = styleWithRoutingMode(edge.style || next, 'manual')
|
|
1653
1785
|
if (kind === 'straight') delete edge.points
|
|
1654
1786
|
else if (kind === 'curved' && (Array.isArray(edge.points) === false || edge.points.length === 0)) {
|
|
1655
1787
|
const bow = arcWaypointFor(doc, edge)
|
|
@@ -1663,6 +1795,14 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1663
1795
|
const node = doc.nodes[ni]
|
|
1664
1796
|
const nodeBase = node.style === undefined ? DEFAULT_NODE_STYLE : node.style
|
|
1665
1797
|
const nextStyle = nodeStyleFromOp(nodeBase, op, 'ops[' + i + '] setStyle')
|
|
1798
|
+
const keys = op.keys && typeof op.keys === 'object' && !Array.isArray(op.keys) ? op.keys : {}
|
|
1799
|
+
const styleText = typeof op.style === 'string' ? op.style : ''
|
|
1800
|
+
const colorChanged = has(op, 'fontColor') || has(keys, 'fontColor') || /(?:^|;)\s*fontColor\s*=/.test(styleText) ||
|
|
1801
|
+
(nodeShapeFromStyle(nextStyle) === 'text' && styleText.length > 0 && !/[=;]/.test(styleText))
|
|
1802
|
+
const sizeChanged = has(op, 'fontSize') || has(keys, 'fontSize') || /(?:^|;)\s*fontSize\s*=/.test(styleText)
|
|
1803
|
+
if (typeof node.labelHtml === 'string' && (colorChanged || sizeChanged)) {
|
|
1804
|
+
node.labelHtml = clearRichTextOverrides(node.labelHtml, { color: colorChanged, size: sizeChanged })
|
|
1805
|
+
}
|
|
1666
1806
|
if (nextStyle !== nodeBase) node.style = nextStyle
|
|
1667
1807
|
if (has(op, 'w')) node.w = numberOr(op.w, node.w)
|
|
1668
1808
|
if (has(op, 'h')) node.h = numberOr(op.h, node.h)
|
|
@@ -1930,7 +2070,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1930
2070
|
const outName =
|
|
1931
2071
|
typeof op.out === 'string' && op.out.length > 0
|
|
1932
2072
|
? op.out
|
|
1933
|
-
: basenamePath(drawioPath).replace(/\.drawio$/i, '') + '-' + resolved.meta.clip + '.svgvideo.zip'
|
|
2073
|
+
: basenamePath(drawioPath).replace(/\.(?:drawio|drawai)$/i, '') + '-' + resolved.meta.clip + '.svgvideo.zip'
|
|
1934
2074
|
if (typeof opctx.writeWorkspaceFile !== 'function') {
|
|
1935
2075
|
throw new Error(where + ': cannot write export file')
|
|
1936
2076
|
}
|
|
@@ -1949,7 +2089,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
1949
2089
|
const outName =
|
|
1950
2090
|
typeof op.out === 'string' && op.out.length > 0
|
|
1951
2091
|
? op.out
|
|
1952
|
-
: basenamePath(drawioPath).replace(/\.drawio$/i, '') + '-' + resolved.meta.clip + '-' + source.rel
|
|
2092
|
+
: basenamePath(drawioPath).replace(/\.(?:drawio|drawai)$/i, '') + '-' + resolved.meta.clip + '-' + source.rel
|
|
1953
2093
|
if (typeof opctx.writeWorkspaceFile !== 'function') {
|
|
1954
2094
|
throw new Error(where + ': cannot write export file')
|
|
1955
2095
|
}
|
|
@@ -2304,6 +2444,62 @@ export const inject = ['tools', 'fs', 'sessions', 'sandboxPolicy', 'webServer']
|
|
|
2304
2444
|
*/
|
|
2305
2445
|
const SKILL_BODY = `# DrawAI 画布:怎么读、怎么改
|
|
2306
2446
|
|
|
2447
|
+
## 视频节点与工作流编排
|
|
2448
|
+
|
|
2449
|
+
画一条视频分十步,执行者就是你自己。**第 0 步(问人)、品牌素材、定卡并连卡、分镜表、样片,这五步不能跳**。
|
|
2450
|
+
|
|
2451
|
+
0. 先问人,再动手 — **没问过风格就开工是最贵的错**。用 ask_user_question 一次问齐这四件事(不要每步都打断):
|
|
2452
|
+
- 风格与气质:暗底开发感 / 明亮克制 / 高饱和冲击 / 复古纸质 / 电影感黑金……有没有参考片、参考站;
|
|
2453
|
+
- 主题与受众:给谁看(开发者、客户、投资人),最想让人记住的一件事是什么;
|
|
2454
|
+
- 规格:时长、画幅(16:9 横屏 / 9:16 竖屏)、要不要旁白与字幕、要不要真声音乐;
|
|
2455
|
+
- 品牌资产从哪来:官方 logo/字标、图标、产品界面截图 —— 用户直接给文件,还是授权你去官方站取。
|
|
2456
|
+
能推断的先推断,写成默认项一起问("我按 16:9 / 30s / 不要旁白来做,除非你说别的")。用户回"你定"就自己定,然后明确说你定了什么。**风格没定之前不要 create 工程**;答案要落进 BRIEF.md 与分镜表。
|
|
2457
|
+
问完不要直接开工:先给 **2–3 个风格方向**(每个 6 行以内:一句话概念 / 参考质感 / 运镜语汇 / 适合什么内容 / 不适合什么),让他选一个。给"暗底开发感 / 明亮克制"这种空词没有用,要给看得见的画面。
|
|
2458
|
+
|
|
2459
|
+
1. 素材与品牌(先收集,再动笔)— 片子里会出现的一切"真东西"都先收齐,不许凭印象画:
|
|
2460
|
+
- **要整个站点就用 gather**:motion_assets {action:"gather", url, brand, pages, limit, dryRun} 扫首页 + 导航里挑出的内页(默认 4 页),把 favicon、og:image、主视觉、产品截图、内联字标一次收齐,跨页聚合**实测配色**(hex + 出现次数)与字体,再补三条品牌 logo 兜底(Simple Icons 矢量 / Clearbit 512 / 高清 favicon),并给每页一张 2 倍视口截图。先 dryRun:true 看清单,再正式收(默认 12 张)。品牌名与域名都写不准时,只给 brand 也能先拿到 logo。
|
|
2461
|
+
- **只关心一个页面就用 collect**:{action:"collect", url},同样的扫描,只扫这一页(默认 8 张)。
|
|
2462
|
+
- **官方品牌资产**:web_search 找「<品牌> brand guidelines / press kit / logo svg」→ read_page 读规范(最小留白、禁止变形、标准色、黑白版规则)→ logo 具体直链用 {action:"fetch", url} 取原始矢量(别截图、别重画)。
|
|
2463
|
+
- **用户能给文件就用用户的**({action:"capture", path});界面要分层或只截一块,用 {action:"page", url} 再 {action:"crop"}。
|
|
2464
|
+
- 找不到官方素材就直说,把"用纯文字占位"当成待确认提案问用户 —— 不要默默换成一个"看起来像"的字体(一眼假,最掉价);
|
|
2465
|
+
- 截图里影响版面的水印、乱码,用 crop 裁掉或换素材。
|
|
2466
|
+
收完先把返回的 colors 与 fonts 对进 theme.ts:**页面实测值**比凭感觉调色更像;fontSources 里 googleFonts 为 true 的可以照 cssUrl 去取真字体,其余退回系统字(在 BRIEF.md 里写明"字体非官方")。素材不止图片:图标、纹理、产品界面、波形、色板都算,缺哪类就补哪一个 url。
|
|
2467
|
+
规矩:不改比例、不改描边、不重上色(官方允许的黑白版除外);字标的大小写、字重、字距按官方来。
|
|
2468
|
+
|
|
2469
|
+
2. 定镜头表(设计,不写代码)— 先用 shotcraft_search 按关键词/分类找准确的卡名与样式;**对选中的卡再用 cardId+styleKey 搜一次**,把它的配方(recipe:参数表、动效核心、已知坑)和 demo 源码读回来。跳过这一步,卡就只是个标签。然后用 diagram_apply addNode {shape:"shotcard",cardId,styleKey,x,y,as:"shot"} 建卡、addEdge 从卡连到视频节点,连线顺序就是镜头顺序。**建了卡就必须连**:没连的卡不会进 assets、不参与排期,create 会直接报错——断连不是"只作参考",是三样一起丢。
|
|
2470
|
+
如果这条片子要的是**整片视觉语言**(扁平矢量、线条、等轴、拼贴、包豪斯、像素、HUD…),先用 mg_style {action:"list"} 挑一种,{action:"read", slug} 读它的施工包(判据/色板/节奏/移植 brief),再 diagram_apply addNode {shape:"mgstyle", slug} 连到视频节点。两者分工:风格管整片(色板、字体、节奏、动效语法),镜头卡管单镜运动方式,可以同时用;一条片子只用一种整片风格。
|
|
2471
|
+
**引擎是硬约束**:read 返回里的 engines 就是上游真用的引擎(three / webgl2 / webgl / canvas2d / dom),engineFiles 是证据文件。上游用 three.js 或 WebGL2 的,移植必须真的跑同一套引擎——three 或 @remotion/three 在 canvas 上渲染,或原生 WebGL2 + GLSL;**不许用 CSS 3D、div 层叠、Canvas2D 渐变模糊「简化」替代**。STYLE.md 的「引擎契约」写了上游证据与逐引擎做法;motion_render 启动前会扫工程源码,重引擎缺失直接拒绝渲染。
|
|
2472
|
+
**动效也是硬约束**:read 返回里的 motion 是从上游源码抽出来的动效画像(转场是 wipe 还是切、有没有 impact/冲击环、相机漂移、motion blur 几采样、颗粒多粗、有哪些二次运动)。**写场景前先 mg_style {action:"frames", slug} 取 8 张参考帧,用 read_image 逐张看**——brief 只写「是什么」,帧才写「怎么动」。STYLE.md 的「效果配方」是必做项:只把形状做对、转场用淡入淡出、落地不压不抖、颗粒几百个点,出来的就是「形似而动不像」,这是最常见也最贵的返工。
|
|
2473
|
+
3. 写分镜表(画布上是**一张表**,不是一镜一个节点)— motion_spec {action:"scaffold", nodeId, storyboard:[{intent 这一镜要做什么, onScreen 屏幕上出现什么, copy 配什么字, card 镜头卡}], style, narration} 返回把【分镜表】/【风格】/【旁白】摆到视频附近空位的 diagram_apply ops,ops 原样交给 diagram_apply(layout:"none")。表是 Markdown 六列(镜头 | 帧区间 | 镜头卡 | 这一镜要做什么 | 屏幕上出现什么 | 文案 / 声音),**每一镜写成表里的一行**;帧区间写 \`0–35f\` 或 \`1.5s\`,各镜之和必须等于视频节点时长。
|
|
2474
|
+
**绝对不要一镜建一个文本节点**——那样 motion_project 读不到分镜,画布上也不再是一张表(motion_spec {action:"validate"} 会把这件事故直接报出来)。**没有分镜表不要开写场景**;功能点对不上镜头就先改表。
|
|
2475
|
+
4. 建工程 — motion_project {action:"create", nodeId, spec:{width,height,fps,duration}, prompt}。没显式给 storyboard 时它**直接读画布上的【分镜表】**(所以上一步那张表就是真相源),按镜头卡排 timeline.json,段数=卡数;没挂卡时返回里会明确写「均分,未挂镜头卡」并给 warning。**想控制总时长不用断连**:显式给 spec.duration 时,卡片按相对时长等比缩放到总长,不会被卡的总和改短。
|
|
2476
|
+
5. 样片(pilot)— 除非用户明确要求跳过,先做可在画布播放的样片,便于及时检查:
|
|
2477
|
+
- 先只写 scene-0,motion_preview {nodeId} 看一帧定调,把纸色、字重、留白、动效气质定住;不对就改 scene-0 重看,别写完四段再回头返工;
|
|
2478
|
+
- 挑能代表整片风格的 3–6 秒:对主视频节点调用 motion_render {action:"start", nodeId:"主视频节点", range:[起,止]},记下返回的 pilot jobId;需要时用同一主视频节点和 jobId 调 status,确认 done。文件 .out/pilot.mp4 出现或静帧预览成功都不代表已展示给用户;
|
|
2479
|
+
- **样片渲染完成后必须主动回填**:在同一画布、同一页用 diagram_apply addNode {shape:"video", label:"样片", x, y, w:480, h:270} 新建节点并记住其 ID(已有合适样片节点时复用);再调用 motion_render {action:"apply", nodeId:"样片节点 ID", jobId:"pilot jobId", source:"pilot"}。核对返回 kind=pilot、nodeId 和画布节点,向用户指出样片位置;失败则说明错误和文件路径。不要把样片写进主视频节点,主节点留给整片;
|
|
2480
|
+
- 样片要有真实排版、真实字重、真实配色、至少一处动效,**不是静态海报**;发给用户的是**渲染出来的 mp4**(回填到节点),不是静帧拼图——拼图会把 1920 缩到 650 宽,细线全糊,双方都会误判;
|
|
2481
|
+
- 用户要求先看样片再继续时,展示后等待反馈;用户已要求直接完成时可继续制作,不额外设置确认关卡。用户明确要求跳过样片时按其要求执行。
|
|
2482
|
+
6. 写画面 — 用 read/edit 直接改 scene-<n>.tsx,对照读回来的 demo 源码复刻动作顺序、缓动、错峰。一场景一文件,单文件不超过 120 行;共享原语放 components/,上游组件可直接 import(别名 @shotcraft/lib/DigitRoll、@shotcraft/demos/<路径>)。场景里写相对本段的帧号,时间轴只改 timeline.json 的 startFrame/durationFrames。
|
|
2483
|
+
7. 看图与补素材 — 改完必看,不要凭想象交付:motion_preview {nodeId, frames:[...]} 同一轮返回拼图,第一次要打包(10–30 秒),之后复用缓存只要几秒。**每镜写完做一次"同段对照"**:把 mg_style frames 的参考帧与 motion_preview 的成片帧并排看,按 STYLE.md「效果配方」逐项打勾(转场/冲击/相机/运动模糊/颗粒/二次运动),对不上就改,别等整片。缺真图时用 motion_assets:action:"capture" 把原始字节搬进 assets/(同时给尺寸与色板),action:"crop" 取局部,**action:"page" 用无头 Chrome 把一段 HTML 模板或网址截成 2 倍图**(写产品界面镜头用它,别自己先截图)。场景里 staticFile("文件名") 取。画布快照会被缩放重绘,精度要求高就走这一步。
|
|
2484
|
+
8. 配声音与踩点 — 音频和画面同一条时间轴(timeline.json 的 audio[]),渲染时由 entry 统一挂 <Audio>,不是成片后再混。motion_audio {action:"add", nodeId, file 或 audioNodeId, role:"bgm"|"voice"|"sfx", at:秒, gainDb, fadeIn, fadeOut, duck} 放置音轨(素材没进工程会自动捕获);role:"bgm" 默认 -10dB 且旁白自动避让。音效优先检查用户素材和本地库;不合适时再用 motion_sfx:search 按声音需求搜索,get 下载并保存音频索引,返回里的 suggestedGainDb 直接当 gainDb 用。踩点用 motion_audio {action:"analyze"} 量 BPM/网格残差/重音三分类/音源峰值滞后,bpm 为 null 就别硬对;action:"apply" 把切点吸到拍子上,带 cueId 可把某条音轨钉到某一帧(自动扣音源滞后与 1.29 帧输出偏移)。
|
|
2485
|
+
9. 出片 — motion_render {action:"start"} 立刻返回 jobId,绝不等待;用 action:"status" 查进度。只渲几帧用 frames;range 渲出来的是**样片(pilot)**,不要拿它当整片。出片后 action:"apply" 把 film.mp4 回填成节点上的 clip pack(画布上直接能播)。
|
|
2486
|
+
**成片必须自己写回**:motion_qa 一过就立刻 motion_render {action:"apply", nodeId:"主视频节点 id"} 回填到主视频节点——apply 找的是最近一次**整片** film 任务,不会拿样片顶替;不要等用户开口要,他看不到工作区里的 mp4,只看得见画布上的节点。回填后海报取片长 35% 处(深色片子 0.4s 是黑帧,缩略图会像空节点)。
|
|
2487
|
+
|
|
2488
|
+
**最常翻车的六件事**:① 没问风格就开工,整片气质跑偏,返工最贵;② 拿系统字体假装品牌字标(用户一眼看出不是官方 logo);③ 只有一张静帧就说"定调好了",没出样片就让人看整片;④ 镜头卡建了却没连线——参考、顺序、段长一起丢,画布上看着"有卡",工程里却没排期;⑤ 重引擎风格(three / WebGL2)用 CSS 或 Canvas2D 简化替代——静帧能骗、一动就露,render 会直接拦;⑥ 只做"形"不做"动"——形变过圆、转场靠淡入淡出、落地不压不抖、颗粒像噪点,没看参考帧就写场景,成品和上游差一个档。
|
|
2489
|
+
|
|
2490
|
+
素材工作流:用户提供的素材和品牌资源优先;需要纸纹、颗粒、胶带、敲击或乐器单音时,先用 motion_assets {action:"list",nodeId} 看工程素材,再用 {action:"library",nodeId,query:"纸张"} 或 query:"钢琴" / "颗粒" 按需查插件素材库;选中后调用 {action:"use",nodeId,assetId:"返回条目的 id"} 导入,只能用返回的 file。图片在场景里用 staticFile(file),声音再用 motion_audio {action:"add",nodeId,file,role:"sfx",at:秒} 上时间轴,仅导入不会自动出现在画面或声音里。VCSL 乐器采样是短单音,不是完整背景音乐;检查电平并在样片中试听。现有素材不合适时再找其他来源。素材来源和许可见返回信息,不要将未逐项列明的许可描述为已确认。
|
|
2491
|
+
插件素材随安装包提供,路径由 motion_assets 管理;上游路径只是素材 id,不是工作区中的磁盘路径。不要猜测插件或上游目录、从其他工程复制同名文件,也不要手写 assets/manifest.json。使用 library 返回的 id 调 use,由工具校验原文件并登记哈希和许可;导入失败时报告具体错误,排查插件安装或环境,不要静默绕过。
|
|
2492
|
+
以上素材工程配合 motion_project / motion_render;motion_task 使用单独的画布引用素材快照,不会自动读取此工程的 assets。要给 motion_task 使用,需将对应素材导入画布节点并连接视频节点,或使用 motion_project / motion_render 完成制作。
|
|
2493
|
+
|
|
2494
|
+
音频素材:audio_asset search/get/import 添加音效、音乐或旁白节点;motion_audio add 也接受 file(本地 mp3/wav/m4a)或画布上的音频节点。生成音乐、TTS 与文本生成音效服务尚未接入。
|
|
2495
|
+
镜头卡是设计参考,**不进成片画面**;但必须连到视频节点——连线决定它进不进 assets、排第几镜、这一镜多长。想换顺序改连线(或同时改 timeline.json),不要改场景文件里的绝对帧号。
|
|
2496
|
+
**引擎保真**:选了带引擎的风格,顺序是 读 engines/engineFiles → 用 mg_style action:"source" 读上游那套引擎的实现文件 → 在 scene-*.tsx 里真跑这套引擎(three/@remotion/three、原生 WebGL2+GLSL、或该风格本来就用的 Canvas2D/DOM)→ motion_preview 看帧 → 再出样片。brief 是移植说明,不是换实现的许可;确实要换,先在 STYLE.md 写「偏离:引擎 X 改为 Y,因为 Z」,render 会带 warning 通过。用 CSS/div/静态图冒充实 3D 或 shader,等于把这个风格的判据扔掉。
|
|
2497
|
+
**画布布局与连线**:先用 diagram_read 查看已有节点、画布尺寸和连线,保留用户已有的位置;按阅读方向分组,同类节点对齐、留出节点和文字间距,并为连线预留清晰通道。视频规格、视频/样片与镜头卡可分区,空间不足时选择另一侧或下方的空位。工作流用圆角折线,**不要使用自动避障**:AI 先确定节点实际 x/y/w/h,再选择合适的 exit/entry,根据画布坐标亲自规划水平/垂直通道和中间折点,以 addEdge {line:"rounded", points:[{x,y},...]} 写入;折点不包含端点。节点或参考的实际位置尚未确定时,先完成布局、diagram_read 读回坐标,再用 setEdge {id,points:[...]} 写入折点。必要时扩大间距,主动检查折线不穿节点或文字、不产生多余折返、交叉或重叠;对齐而不需要转弯时 points 可为空。使用 layout:"none" 保留已规划几何;用户要求自动布局时可先布局再重新规划连线。调整已有自动线时先 setStyle {avoid:false},再 setEdge 指定折点;保留其他用户明确自定义的线型和未要求修改的手工路径。
|
|
2498
|
+
**折点与端点分开**:exit/entry 决定节点连接侧,插件会补端点及引出段;points 只填确实需要的中间转折,绝不放源/目标连接点或重复折点。例:A=(60,60,240,140) 右侧中点为 (300,130),B=(400,40,480,270) 左侧中点为 (400,175);从 A 右侧到 B 左侧可用 exit:"e",entry:"w",points:[{x:340,y:175}],不要再加 {x:400,y:175}。若起终点沿进出方向对齐且通道畅通,使用 points:[]。连线后先读回坐标和折点,具备图像通道时用 diagram_read {render:true} 查看实际画布;出现折返、穿线或覆盖文字时用 setEdge 修正,再复查。预览不可用时如实说明未做视觉校验。
|
|
2499
|
+
参考图:diagram_apply 连接图片节点后会把画布快照冻结进工程 assets/,场景里通过 props.assets 取用。视频参数(宽高/fps/duration)在 motion_project create 时写入节点;用户指定时长必须显式传 duration(支持 9.3 等小数秒),不能只写在 prompt 里。
|
|
2500
|
+
旧任务记录用 motion_task list/status/cancel/apply/focus/remove 管理,不要再指望它 start。多页或多个视频时明确指定 page/nodeId。
|
|
2501
|
+
不要把这条流程想成"提交一个任务等结果":你是在一个普通工程里写代码、看图、渲染,每一步都由本次对话直接完成。
|
|
2502
|
+
|
|
2307
2503
|
## 一句话
|
|
2308
2504
|
工作区里任何一个 .drawio 文件**就是**一张 DrawAI 画布 —— 它是 drawio 自己的 mxfile 格式。
|
|
2309
2505
|
用户在 DSH 右栏画布面板里看到的、以及用 drawio 桌面版打开的,是同一份文件;
|
|
@@ -2325,8 +2521,7 @@ const SKILL_BODY = `# DrawAI 画布:怎么读、怎么改
|
|
|
2325
2521
|
2. **diagram_apply(path, ops, layout?, expectRevision?)** —— 再改。给一组结构化编辑,宿主**无损写回**:只改我们拥有的单元,
|
|
2326
2522
|
文件其余部分(别的页、未知单元、自定义属性)逐字节保留。打开后原样保存 = 文件一个字节都不变。
|
|
2327
2523
|
|
|
2328
|
-
|
|
2329
|
-
用户说"这张图"时通常不用传 path。改完可以再 read 一次自查。
|
|
2524
|
+
写入必须显式传 path。用户说"这张图"时,先用 diagram_read 读取当前聚焦画布,再把返回的 path 传给 diagram_apply;无法确认目标时先打开画布。改完可以再 read 一次自查。
|
|
2330
2525
|
|
|
2331
2526
|
**当前是哪一张,有两个来源**:用户在画布面板里切换标签页时,宿主会把"当前画布 = <路径>"
|
|
2332
2527
|
**注入到会话里**(你会看到一条 plugin 来源的上下文);不放心时也不带 path 调一次
|
|
@@ -2340,13 +2535,15 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
2340
2535
|
改过、或另一个会话动过)调用会**直接失败、一个字节都不写** —— 重新 read 一次拿新指纹再改。
|
|
2341
2536
|
不传就是不检查(并发编辑时可能覆盖别人的改动,所以"读过再改"的流程里最好都传)。
|
|
2342
2537
|
|
|
2538
|
+
手动布线:先读取或确定节点位置,按画布实际 x/y/w/h 为每条工作流边设计避开节点和文字的水平/垂直通道;addEdge / setEdge 用 points:[{x,y},...] 按源端到目标端顺序指定中间折点(不含端点),绝对画布坐标、保留小数、不吸附网格,最多1000个。显式 points 切入手动模式;不要同时给 avoid:true。points:[] 清空并保持手动,省略 points 保留原折点。仅用户明确要求自动避障时使用 setStyle{avoid:true,clearPoints:true};仅用户明确要求非正交逐段连线时使用 line:"straight"。先确定节点位置,再用 layout:"none" 写入并复查。
|
|
2539
|
+
|
|
2343
2540
|
## ops 速查
|
|
2344
|
-
{op:"addNode", label?, as?, shape?, svg?, style?, keys?, w?, h?, x?, y?, fontColor?}
|
|
2345
|
-
{op:"addEdge", from?, to?, fromPoint?, toPoint?, as?, label?, style?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, keys?}
|
|
2541
|
+
{op:"addNode", label?, as?, shape?, svg?, style?, keys?, data?, w?, h?, x?, y?, fontColor?}
|
|
2542
|
+
{op:"addEdge", from?, to?, fromPoint?, toPoint?, as?, label?, points?, style?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, keys?}
|
|
2346
2543
|
{op:"setLabel", id|ids, label}
|
|
2347
2544
|
{op:"setLabelPos", id, x?, y?, dx?, dy?, center?}
|
|
2348
2545
|
{op:"setStyle", id|ids, shape?, svg?, style?, keys?, w?, h?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, clearPoints?}
|
|
2349
|
-
{op:"setEdge", id, from?, to?, fromPoint?, toPoint?}
|
|
2546
|
+
{op:"setEdge", id, from?, to?, fromPoint?, toPoint?, points?}
|
|
2350
2547
|
{op:"order", id|ids, to:"front"|"back"|"up"|"down"}
|
|
2351
2548
|
{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?}
|
|
2352
2549
|
{op:"addLayer", name?, id?, visible?, as?}
|
|
@@ -2449,7 +2646,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
2449
2646
|
偏移随保存、复制、撤销保留;移动节点时文字跟随,导出与适应内容包含节点外的文字。
|
|
2450
2647
|
**视频节点(旁路媒体包)**:
|
|
2451
2648
|
- **用户导入入口**:右侧「视频」先选本地文件,再点击画布放置;也可拖入视频或 Ctrl+V 粘贴剪贴板中的视频文件。
|
|
2452
|
-
仅支持浏览器可解码的 MP4/WebM/MOV/MKV
|
|
2649
|
+
仅支持浏览器可解码的 MP4/WebM/MOV/MKV;视频分块上传。画布须先保存,完整上传后才创建有封面的原视频节点,不创建空占位。
|
|
2453
2650
|
- addNode {shape:"video", fps?, w?, h?, mediaKind?, mediaClip?, x, y} → 海报 + \`*.media/<clip>/\`
|
|
2454
2651
|
- listFrames / getFrame / setFrame / appendFrames / setMediaMeta / importMedia
|
|
2455
2652
|
- extractFrames(看 stills 计划)/ traceFrame(stills → SVG 帧)
|
|
@@ -2524,14 +2721,15 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
2524
2721
|
expectRevision:"<上一步 read 回来的 revision>" })
|
|
2525
2722
|
|
|
2526
2723
|
## 布局与坐标
|
|
2527
|
-
-
|
|
2724
|
+
- 工作流使用圆角折线;AI 自行根据节点坐标确定水平/垂直通道和 points 折点,不启用自动避障。按流程方向分层、同类对齐、间距一致,检查连线不穿节点或文字。保持用户明确指定的其他线型。
|
|
2725
|
+
- 简单流程优先使用自动布局:layout 选 dagre-tb(默认,贴合右栏窄高的形状)、dagre-lr(左到右)、
|
|
2528
2726
|
grid(网格)、none(保留现有坐标,只给没有坐标的新节点补位)。
|
|
2529
2727
|
- 只有"必须摆在某个位置"时才传 x/y(宿主仍会把它吸附到网格)。
|
|
2530
2728
|
- 画布的吸附单位:**节点 10px,连线的折点/自由端点 5px**。手工摆过的画布带 meta.pinned,
|
|
2531
2729
|
这时**不加 layout 就不会重排**(否则你一改图,人手工排好的版面就被冲掉);想重排必须显式给 layout。
|
|
2532
2730
|
- **ops 里自带几何时(addNode 给了 x/y、或者有 move)也不会重排** ——
|
|
2533
2731
|
否则"把 n1 往右挪 40"会被布局立刻冲掉,看起来像工具坏了。
|
|
2534
|
-
-
|
|
2732
|
+
- 显式重排会重算已启用自动避障的旧连线,手动折点和固定连接点保留;节点位置改变后由 AI 复核并按需更新手动折点。新建连线默认手动模式。
|
|
2535
2733
|
- AI 改完的改动**不在用户本地的撤销历史里**(服务端推新版本时客户端会清空它,免得一次 Ctrl+Z
|
|
2536
2734
|
把 AI 的改动悄悄顶掉)。宿主留了一个**回退栈**(最多 8 步),用户界面「编辑 → 撤销 AI 改动」
|
|
2537
2735
|
每按一次退一步;read 返回里的 revertSteps 就是"还剩几步",canRevert 是"还有没有"。
|
|
@@ -2569,7 +2767,7 @@ HTML 标签按纯文本显示、图层结构原样保留(画布把它们叠在
|
|
|
2569
2767
|
|
|
2570
2768
|
## 别做这些
|
|
2571
2769
|
- 别直接改 .drawio 文件的文本(用 diagram_apply):手改会破坏"没动的地方逐字节不变"这条保证。
|
|
2572
|
-
-
|
|
2770
|
+
- 新建优先使用 .drawai;别造其他后缀(.json/.xml/.svg):画布支持 .drawai 原生 JSON 和 .drawio;其他后缀会"看起来成功、其实没人能打开"。
|
|
2573
2771
|
- 别把图重画一遍来表达"挪一下":用户手工摆过的位置是有意义的。
|
|
2574
2772
|
|
|
2575
2773
|
## 用户可以这样操作(他问"怎么做"时照这个答)
|
|
@@ -2589,7 +2787,7 @@ HTML 标签按纯文本显示、图层结构原样保留(画布把它们叠在
|
|
|
2589
2787
|
const SKILL_SUMMARY = {
|
|
2590
2788
|
name: 'drawai-canvas',
|
|
2591
2789
|
description:
|
|
2592
|
-
'读写 DrawAI 画布(.drawio):diagram_read 看现状、diagram_apply
|
|
2790
|
+
'读写 DrawAI 画布(.drawio):diagram_read 看现状、diagram_apply 结构化改图;做视频先问清风格与品牌素材(motion_assets gather 可整站自动收集素材/配色/字体/logo)、出过样片再开拍;支持图像复刻、视频分段复刻及 SVG 帧序列导出。',
|
|
2593
2791
|
whenToUse:
|
|
2594
2792
|
'用户让你画流程图/架构图/泳道图、复刻图片或视频、将视频转为 SVG 帧动画,或让你读/改某张 .drawio 画布,或问"这张图里有什么""画布上怎么操作"时。',
|
|
2595
2793
|
}
|
|
@@ -2637,7 +2835,7 @@ async function listCanvases(root, cwd, dir) {
|
|
|
2637
2835
|
: false
|
|
2638
2836
|
const rel = prefix.length === 0 ? name : prefix + '/' + name
|
|
2639
2837
|
// 载体就是 .drawio —— 列出来的每一个都能直接打开(不再有「先导入成别的东西」这一步)。
|
|
2640
|
-
if (!isDir && name
|
|
2838
|
+
if (!isDir && isCanvasPath(name)) {
|
|
2641
2839
|
if (seen[rel] !== true) {
|
|
2642
2840
|
seen[rel] = true
|
|
2643
2841
|
found.push(rel)
|
|
@@ -2731,7 +2929,22 @@ export function apply(ctx) {
|
|
|
2731
2929
|
|
|
2732
2930
|
async function resolveTarget(path, sessionId) {
|
|
2733
2931
|
const root = workspaceRootOf(sessionId)
|
|
2734
|
-
|
|
2932
|
+
const target = root === undefined ? await ctx.fs.resolve(path) : await ctx.fs.resolve(path, { cwd: root })
|
|
2933
|
+
// A retained legacy path continues to address its migrated document for tools
|
|
2934
|
+
// and queued tasks, including after a host restart. Never adopt an unrelated sibling.
|
|
2935
|
+
if (root !== undefined && typeof ctx.fs.stat === 'function' && /\.drawio$/i.test(String(path))) {
|
|
2936
|
+
const native = await ctx.fs.resolve(nativeCanvasPath(path), { cwd: root })
|
|
2937
|
+
const rootTarget = await ctx.fs.resolve(root)
|
|
2938
|
+
if (ctx.fs.contains(rootTarget, target) && ctx.fs.contains(rootTarget, native) && await ctx.fs.stat(native)) {
|
|
2939
|
+
let project
|
|
2940
|
+
try { project = JSON.parse(await ctx.fs.readText(native)) } catch {}
|
|
2941
|
+
if (project?.format === 'dsh-drawai' && project.migration?.sourcePath === absoluteHint(target, path)) {
|
|
2942
|
+
if (await ctx.fs.stat(target) && contentHash(await ctx.fs.readText(target)) !== project.migration.sourceHash) throw new Error('旧画布已在迁移后更改,请处理两个版本')
|
|
2943
|
+
return native
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
return target
|
|
2735
2948
|
}
|
|
2736
2949
|
|
|
2737
2950
|
/**
|
|
@@ -2773,6 +2986,10 @@ export function apply(ctx) {
|
|
|
2773
2986
|
*/
|
|
2774
2987
|
function renderDocText(originalText, doc, options) {
|
|
2775
2988
|
const doc2 = normalizeDoc(doc)
|
|
2989
|
+
if (typeof originalText === 'string' && originalText.trim()) {
|
|
2990
|
+
if (options?.format === 'drawio' && originalText.trimStart().startsWith('{')) originalText = exportCanvasToMxfile(originalText)
|
|
2991
|
+
else if (options?.format === 'drawai' && !originalText.trimStart().startsWith('{')) originalText = migrateMxfile(originalText)
|
|
2992
|
+
}
|
|
2776
2993
|
if (typeof originalText !== 'string' || originalText.trim().length === 0) {
|
|
2777
2994
|
const built = buildMxfile(doc2, options)
|
|
2778
2995
|
return { text: built.text, dropped: built.dropped, revision: contentHash(built.text) }
|
|
@@ -2788,7 +3005,7 @@ export function apply(ctx) {
|
|
|
2788
3005
|
* 而那些文件没有画布认领、也不会被「打开」列出来,属于"看起来成功、其实没人能看见"。
|
|
2789
3006
|
*/
|
|
2790
3007
|
function assertCanvasPath(path) {
|
|
2791
|
-
if (typeof path !== 'string' || path
|
|
3008
|
+
if (typeof path !== 'string' || !isCanvasPath(path)) {
|
|
2792
3009
|
throw new Error('画布路径必须以 .drawio 结尾(drawio 的 mxfile 就是画布文件):' + String(path))
|
|
2793
3010
|
}
|
|
2794
3011
|
}
|
|
@@ -2814,9 +3031,7 @@ export function apply(ctx) {
|
|
|
2814
3031
|
path: {
|
|
2815
3032
|
type: 'string',
|
|
2816
3033
|
description:
|
|
2817
|
-
'
|
|
2818
|
-
DEFAULT_PATH +
|
|
2819
|
-
')—— 想知道用户在看哪张,直接不传 path 调一次,返回里的 path 就是它。',
|
|
3034
|
+
'工作区相对路径或绝对路径。不传时读取当前会话明确聚焦的画布;没有焦点时请指定 path。',
|
|
2820
3035
|
},
|
|
2821
3036
|
page: {
|
|
2822
3037
|
type: 'number',
|
|
@@ -2921,6 +3136,10 @@ export function apply(ctx) {
|
|
|
2921
3136
|
id: { type: 'string', required: true },
|
|
2922
3137
|
label: { type: 'string', required: true },
|
|
2923
3138
|
shape: { type: 'string', required: true },
|
|
3139
|
+
audio: { type: 'string' }, cardId: { type: 'string' }, styleKey: { type: 'string' }, shotCommit: { type: 'string' },
|
|
3140
|
+
// 整片风格节点(shape=mgstyle)才会带这两项。漏声明一次,含风格节点的画布
|
|
3141
|
+
// diagram_read 整次失败 —— 与 video 那组字段同一个坑。
|
|
3142
|
+
slug: { type: 'string' }, mgStyleCommit: { type: 'string' },
|
|
2924
3143
|
style: { type: 'string', required: true },
|
|
2925
3144
|
// 内嵌 SVG 内容(shape 为 svg 的节点才有):节点**画什么**就写在这里。
|
|
2926
3145
|
// 上面 style 里的 image= 被省略成 «svg N 字符» —— 别把它写回来(会被拦下),
|
|
@@ -3127,11 +3346,11 @@ export function apply(ctx) {
|
|
|
3127
3346
|
},
|
|
3128
3347
|
},
|
|
3129
3348
|
async execute(args, exec) {
|
|
3130
|
-
//
|
|
3131
|
-
// 两处必须一致 —— 否则 AI 会"读 A、写 B"。
|
|
3349
|
+
// 读写都只接受显式路径或当前会话的焦点;未知目标时不猜 demo。
|
|
3132
3350
|
const focus = focusedPathFor(sessionIdOf(exec))
|
|
3133
3351
|
const path =
|
|
3134
|
-
typeof args.path === 'string' && args.path.length > 0 ? args.path : focus
|
|
3352
|
+
typeof args.path === 'string' && args.path.trim().length > 0 ? args.path : focus
|
|
3353
|
+
if (!path) throw new Error('未指定画布路径且当前会话没有聚焦画布,请传入 path 或先打开目标画布')
|
|
3135
3354
|
assertCanvasPath(path)
|
|
3136
3355
|
const pageArg =
|
|
3137
3356
|
Number.isFinite(Number(args.page)) ? Number(args.page) : focusedPageFor(sessionIdOf(exec))
|
|
@@ -3203,7 +3422,10 @@ export function apply(ctx) {
|
|
|
3203
3422
|
nodes.push({
|
|
3204
3423
|
id: String(n.id),
|
|
3205
3424
|
label: typeof n.label === 'string' ? n.label : String(n.id),
|
|
3206
|
-
shape: shapeName,
|
|
3425
|
+
shape: isAudioNode(n) ? 'audio' : isShotCardNode(n) ? 'shotcard' : isMgStyleNode(n) ? 'mgstyle' : shapeName,
|
|
3426
|
+
...(isAudioNode(n) ? {audio:JSON.stringify({data:n.data,settings:audioNodeSettings(n.data)})} : {}),
|
|
3427
|
+
...(isShotCardNode(n) ? { cardId: n.data.shotCardId, styleKey: n.data.shotStyleKey, shotCommit: n.data.shotCommit } : {}),
|
|
3428
|
+
...(isMgStyleNode(n) ? { slug: n.data.mgStyleSlug, mgStyleCommit: n.data.mgStyleCommit } : {}),
|
|
3207
3429
|
// 报告里把内嵌的 SVG / 位图 data URI 从 style 里省略,免得一次读取被撑爆;
|
|
3208
3430
|
// 写回时带这个标记会被拦住(assertNoStyleElision)。
|
|
3209
3431
|
style: styleForReport(style),
|
|
@@ -3355,10 +3577,10 @@ export function apply(ctx) {
|
|
|
3355
3577
|
name: 'diagram_apply',
|
|
3356
3578
|
description:
|
|
3357
3579
|
'对工作区里的 DrawAI 画布文档施加一组结构化编辑(加节点/连边/改标签/改样式/移动/删除/高亮),然后自动布局并写回文件。布局默认由这里算;ops 里一旦自带几何(addNode 给了 x/y,或有 move)就不再重排,想重排请显式给 layout。' +
|
|
3358
|
-
'ops 的每一项形如 {op:"addNode", label?, as?, shape?, svg?, style?, keys?, w?, h?, x?, y?, fontColor?} / ' +
|
|
3359
|
-
'{op:"addEdge", from?, to?, fromPoint?, toPoint?, as?, label?, style?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, keys?} / ' +
|
|
3580
|
+
'ops 的每一项形如 {op:"addNode", label?, as?, shape?, svg?, style?, keys?, data?, w?, h?, x?, y?, fontColor?} / ' +
|
|
3581
|
+
'{op:"addEdge", from?, to?, fromPoint?, toPoint?, as?, label?, points?, style?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, keys?} / ' +
|
|
3360
3582
|
'{op:"setLabel", id|ids, label} / {op:"setLabelPos", id, x?, y?, dx?, dy?, center?} / {op:"setStyle", id|ids, shape?, svg?, style?, keys?, w?, h?, dash?, arrow?, color?, exit?, entry?, jettySize?, edgeStyle?, line?, avoid?, fontSize?, fontColor?, clearPoints?} / ' +
|
|
3361
|
-
'{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
|
|
3583
|
+
'{op:"setEdge", id, from?, to?, fromPoint?, toPoint?, points?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
|
|
3362
3584
|
'{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?} / {op:"addLayer", name?, as?} / {op:"setLayer", id|ids, layer} / {op:"setLayerProps", layer, name?, visible?, locked?} / ' +
|
|
3363
3585
|
'{op:"export", format?:"svg"|"png", name?} / ' +
|
|
3364
3586
|
'{op:"traceImage", image, removeBg, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
|
|
@@ -3384,6 +3606,8 @@ export function apply(ctx) {
|
|
|
3384
3606
|
'字符紧→scale:2/加大epsilon/preset:simple;线糊断→preset:lineart 或提高 protectLuma/colorsDark。' +
|
|
3385
3607
|
'**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
|
|
3386
3608
|
'**引用刚建的那个**用 as:"名字":{op:"addNode", label:"开始", as:"start"} 之后同一个 ops 数组里 from/to/id 可以直接写 "start",不必猜自动分配的 n7。' +
|
|
3609
|
+
'镜头参考节点:先用 shotcraft_search 搜索,再 addNode {shape:"shotcard",cardId:"准确卡名",styleKey:"准确样式key",x,y,as:"ref"};用 addEdge 从该节点连到视频,支持多卡。' +
|
|
3610
|
+
'整片风格节点:先用 mg_style {action:"list"} 选好风格,再 addNode {shape:"mgstyle",slug:"09-bauhaus",x,y,as:"style"} 连到视频;一条片子只用一种,建工程时它的色板会写成 theme.ts、施工说明写成 STYLE.md。' +
|
|
3387
3611
|
'**画图形(图标/logo/示意图)用 svg**:addNode {shape:"svg", svg:"<svg viewBox=…>…</svg>", w, h} 让节点的内容就是那段 SVG 标记' +
|
|
3388
3612
|
'(不带字时可以不给 label);setStyle {id, svg:"<svg…>"} 换图、{id, svg:null} 清掉回到矩形。' +
|
|
3389
3613
|
'标记**必须带 viewBox**(否则放大缩小时图形不跟着变)、默认拉伸铺满节点框(要等比就用 keys:{imageAspect:1})。' +
|
|
@@ -3398,18 +3622,19 @@ export function apply(ctx) {
|
|
|
3398
3622
|
'文档里存的是 **drawio 的 style 键**(dashed/dashPattern/edgeStyle/jettySize/libavoidRouting/exitX·exitY·entryX·entryY/endArrow·startArrow/strokeColor/fillColor/shape=/rounded=/arcSize=…),默认值一律省略、认不出的键原样保留;' +
|
|
3399
3623
|
'上面这些 shape/style/dash/arrow/color/exit/entry 是给模型用的**糖**,由宿主翻译成 style 键,绝不落盘。' +
|
|
3400
3624
|
'style 既可以是调色板名(plain/blue/green/orange/yellow/red/purple/grey),也可以直接是一段 style 串;keys 用来写任意 drawio 键(值给 null = 删键回默认)。' +
|
|
3625
|
+
'精确布线:addEdge / setEdge 支持 points:[{x,y},...],按源端到目标端顺序指定中间折点(不含端点),绝对画布坐标、保留小数、不吸附网格,最多1000个。显式 points 切入手动模式,自动路由不会覆盖;不能同时给 avoid:true。points:[] 清空并保持手动,省略 points 保留原折点。仅在用户明确要求自动避障时使用 setStyle{avoid:true,clearPoints:true}。正交模式自行规划水平/垂直通道;仅在用户明确要求非正交逐段连线时使用 line:"straight"。先确定节点位置,再用 layout:"none" 写入。' +
|
|
3626
|
+
'端点由连接侧决定,插件自行补引出段;points 不得包含起点、终点或重复点,直接对齐且通道畅通时用 points:[]。例如 A 的右侧连接点为 (300,130)、B 的左侧连接点为 (400,175),指定 exit:"e",entry:"w",points:[{x:340,y:175}];不要将 (400,175) 加为折点,否则可能折返。操作后读回坐标与折点,并在图像通道可用时用 diagram_read render:true 查看实际路径,必要时用 setEdge 调整;无法看图时如实说明。' +
|
|
3401
3627
|
'setStyle 的 id 可以是节点也可以是连线:节点用 shape/style/keys/w/h/fontColor,连线用 dash(solid|dashed|dotted)、arrow(end 单向|both 双向|none 无箭头|start 反向)、color(**线本身**的颜色)、exit/entry(n|e|s|w 进出侧)、jettySize(引出段长度,数字或 auto)、edgeStyle(orthogonalEdgeStyle|none)、line(straight 直线|sharp 直角折线|rounded 圆角折线|curved 曲线)、avoid(是否参与避让路由)、fontSize(字号)、fontColor(**文字**颜色)、clearPoints(清掉折点)。' +
|
|
3402
3628
|
'fontColor 给调色板名(plain/blue/green/orange/yellow/red/purple/grey)就按画布上"文字换色"那一支取色,给 #b85450 这类色值就原样写;fontColor:null 删键回缺省(黑字)。' +
|
|
3403
|
-
'
|
|
3404
|
-
'
|
|
3629
|
+
'工作流新建节点连接默认圆角折线、手动模式。AI 先确定或读回节点坐标,自己规划水平/垂直通道,用 addEdge 的 points 传中间折点;先布局后连线,检查不穿节点、文字,不交叉或重叠。不要启用自动避障。文档若带 meta.pinned(人手工摆过位置),不加 layout 就不会重排;显式重排保留手动折点,移动节点后需复核并按需更新。' + '边引用了不存在的节点会直接报错,且失败发生在写盘之前。自环写成 from === to(与 drawio 一致);自动布局会忽略自环,只摆节点。' +
|
|
3630
|
+
'写入必须显式传 path;不确定目标时先用 diagram_read 读取当前聚焦画布,使用其返回的 path。' +
|
|
3405
3631
|
'多页文件用 page(0 起)指定要改哪一页;不传 = 用户正在看的那一页。其它页字节原样保留。',
|
|
3406
3632
|
parameters: {
|
|
3407
3633
|
path: {
|
|
3408
3634
|
type: 'string',
|
|
3635
|
+
required: true,
|
|
3409
3636
|
description:
|
|
3410
|
-
'
|
|
3411
|
-
DEFAULT_PATH +
|
|
3412
|
-
')—— 想知道用户在看哪张,直接不传 path 调一次,返回里的 path 就是它。',
|
|
3637
|
+
'必填:目标画布的工作区相对路径或绝对路径;先用 diagram_read 确定当前画布。',
|
|
3413
3638
|
},
|
|
3414
3639
|
page: {
|
|
3415
3640
|
type: 'number',
|
|
@@ -3601,12 +3826,9 @@ export function apply(ctx) {
|
|
|
3601
3826
|
},
|
|
3602
3827
|
},
|
|
3603
3828
|
async execute(args, exec) {
|
|
3604
|
-
//
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
const focused = focusedPathFor(sessionIdOf(exec))
|
|
3608
|
-
const path =
|
|
3609
|
-
typeof args.path === 'string' && args.path.length > 0 ? args.path : focused !== undefined && focused.length > 0 ? focused : DEFAULT_PATH
|
|
3829
|
+
// 写入必须显式给路径,避免聚焦上报延迟或缺失时误改另一张画布。
|
|
3830
|
+
const path = typeof args.path === 'string' && args.path.trim().length > 0 ? args.path : ''
|
|
3831
|
+
if (!path) throw new Error('未指定画布路径:请先用 diagram_read 确认目标画布,再显式传入 path')
|
|
3610
3832
|
// **入参是宿主深冻结的**:dsh-tools 在 execute 之前把整份 arguments 深冻结
|
|
3611
3833
|
// (`arguments: deepFreeze(snapshotJsonValue(exec.arguments))`,见 dsh-tools 的 tool 执行器),
|
|
3612
3834
|
// 数组和里面的 op 对象都是 non-extensible。所以这里必须**复制成一份可扩展的数组**再用:
|
|
@@ -3667,6 +3889,8 @@ export function apply(ctx) {
|
|
|
3667
3889
|
const mode = typeof args.layout === 'string' ? args.layout : pinned || noDocEdit || explicitGeometry ? 'none' : 'dagre-tb'
|
|
3668
3890
|
if (LAYOUTS.indexOf(mode) < 0) throw new Error('unknown layout "' + mode + '"; use one of ' + LAYOUTS.join(', '))
|
|
3669
3891
|
|
|
3892
|
+
const routingBefore = JSON.parse(JSON.stringify(doc))
|
|
3893
|
+
|
|
3670
3894
|
const out = {}
|
|
3671
3895
|
const opctx = {
|
|
3672
3896
|
drawioPath: loaded.absolute,
|
|
@@ -3787,7 +4011,7 @@ export function apply(ctx) {
|
|
|
3787
4011
|
const before = nodePositions(doc)
|
|
3788
4012
|
autoLayout(doc, mode)
|
|
3789
4013
|
const dropped = invalidateStalePoints(doc, before)
|
|
3790
|
-
if (dropped > 0) notes.push('~
|
|
4014
|
+
if (dropped > 0) notes.push('~ 重排后端点位置变了,将重算 ' + dropped + ' 条自动路线')
|
|
3791
4015
|
}
|
|
3792
4016
|
// 写回:**在原文件上做无损写回**(只改我们拥有的单元,其余原文逐字节保留)。
|
|
3793
4017
|
// revision 由文件内容算出来,不再是"我们自己 +1"—— 载体即真相,没有第二个计数器。
|
|
@@ -3797,8 +4021,10 @@ export function apply(ctx) {
|
|
|
3797
4021
|
// 那一次没事(mode 已经是 'none',人不人的位置都保住了),但从第二次起 pinned 读不到,
|
|
3798
4022
|
// mode 回落到 'dagre-tb',人手工摆好的版面被整张重排 —— 正是上面那个判断要防的事。
|
|
3799
4023
|
// 教训:用整体赋值覆盖 meta 时,"没被显式处理"的字段会静默消失;新增 meta 字段时先看这里。
|
|
4024
|
+
const routingResult = documentRouter.update(routingBefore, doc)
|
|
4025
|
+
if (routingResult.failed.length) notes.push('~ 自动避障暂无可用通道:' + routingResult.failed.join(', '))
|
|
3800
4026
|
doc.meta = Object.assign({}, doc.meta, { layout: mode }, pinned ? { pinned: true } : {})
|
|
3801
|
-
const rendered = renderDocText(loaded.text, doc, { page: loaded.pageIndex })
|
|
4027
|
+
const rendered = renderDocText(loaded.text, doc, { page: loaded.pageIndex, format: isNativeCanvasPath(loaded.absolute) ? 'drawai' : 'drawio' })
|
|
3802
4028
|
doc.revision = rendered.revision
|
|
3803
4029
|
if (rendered.dropped.length > 0) notes.push('~ ' + rendered.dropped.length + ' 条边的两端都没有落点,已略过:' + rendered.dropped.join(', '))
|
|
3804
4030
|
const policy = policyFor(sessionId)
|
|
@@ -3853,7 +4079,7 @@ function sanitizeNewName(raw) {
|
|
|
3853
4079
|
if (name === '.' || name === '..') return { ok: false, error: '文件名非法' }
|
|
3854
4080
|
// 禁止 Windows 非法字符,省得写盘才失败
|
|
3855
4081
|
if (/[<>:"|?*\u0000-\u001f]/.test(name)) return { ok: false, error: '文件名包含非法字符' }
|
|
3856
|
-
const withExt = name
|
|
4082
|
+
const withExt = isCanvasPath(name) ? name : name + '.drawai'
|
|
3857
4083
|
if (withExt.length > 120) return { ok: false, error: '文件名过长' }
|
|
3858
4084
|
return { ok: true, name: withExt }
|
|
3859
4085
|
}
|
|
@@ -4040,7 +4266,7 @@ function sanitizeNewName(raw) {
|
|
|
4040
4266
|
function baseNameOf(absolute) {
|
|
4041
4267
|
const text = String(absolute)
|
|
4042
4268
|
const at = Math.max(text.lastIndexOf('\\'), text.lastIndexOf('/'))
|
|
4043
|
-
return (at >= 0 ? text.slice(at + 1) : text).replace(/\.drawio$/i, '')
|
|
4269
|
+
return (at >= 0 ? text.slice(at + 1) : text).replace(/\.(?:drawio|drawai)$/i, '')
|
|
4044
4270
|
}
|
|
4045
4271
|
|
|
4046
4272
|
/**
|
|
@@ -4140,7 +4366,7 @@ function sanitizeNewName(raw) {
|
|
|
4140
4366
|
* 注入之后,模型在下一步就能直接看到,不用探。
|
|
4141
4367
|
*
|
|
4142
4368
|
* `agents` 是**可选**服务(走 ctx.get):没有它的部署、或注入形状对不上老版本时,
|
|
4143
|
-
* 都只是少了这条提示 ——
|
|
4369
|
+
* 都只是少了这条提示 —— 工具仍然要求显式 path 或当前会话聚焦画布。
|
|
4144
4370
|
*/
|
|
4145
4371
|
function notifyFocusedCanvas(sessionId, path) {
|
|
4146
4372
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
@@ -4158,8 +4384,8 @@ function sanitizeNewName(raw) {
|
|
|
4158
4384
|
injectedCanvas.set(sessionId, path)
|
|
4159
4385
|
const text =
|
|
4160
4386
|
path.length > 0
|
|
4161
|
-
? 'DrawAI 画布:用户现在打开的是 ' + path + '。diagram_read
|
|
4162
|
-
: 'DrawAI
|
|
4387
|
+
? 'DrawAI 画布:用户现在打开的是 ' + path + '。diagram_read 不传 path 可读取当前焦点;diagram_apply 写入时必须显式传 path。'
|
|
4388
|
+
: 'DrawAI 画布:用户关掉了画布(此刻没有打开任何一张)。请先打开画布或明确指定 path。'
|
|
4163
4389
|
try {
|
|
4164
4390
|
// Session V4 要求 producer-owned source kind:字面量 `'plugin'` 会被拒
|
|
4165
4391
|
// (format v4 message requires a producer-owned source kind)。
|
|
@@ -4192,7 +4418,81 @@ function sanitizeNewName(raw) {
|
|
|
4192
4418
|
// 临时复刻包只由持有本次令牌的会话管理,取消不触及已有媒体。
|
|
4193
4419
|
const videoTraceJobs = new Map()
|
|
4194
4420
|
const videoUploadJobs = new Map()
|
|
4421
|
+
const videoReadTickets = new Map()
|
|
4195
4422
|
const videoExports = createVideoExportManager({ slots: mediaSlots })
|
|
4423
|
+
const motionFocus = new Map()
|
|
4424
|
+
const audioService = createAudioService()
|
|
4425
|
+
const motionTasks = createMotionTaskManager({ slots: mediaSlots,
|
|
4426
|
+
inspectFilm:async(job,composition,previous,final=false)=>{
|
|
4427
|
+
const agent=ctx.get?.('agents')?.get?.(job.scope.sessionId),session=sessionOf(job.scope.sessionId)
|
|
4428
|
+
const config=agent?.session?.requestHeader?.()?.config||session?.requestHeader?.()?.config||agent?.options
|
|
4429
|
+
const llm=ctx.get?.('llm'),attachments=ctx.get?.('attachments')
|
|
4430
|
+
let info
|
|
4431
|
+
try{info=config&&llm?.resolveModelInfo?await llm.resolveModelInfo(config.provider,config.model,job.control.signal):null}
|
|
4432
|
+
catch(error){if(job.control.signal.aborted)throw error}
|
|
4433
|
+
const reviewSequence=info?.inputModalities?.includes('image')&&attachments?.saveImage?motionSequenceReviewer({llm,config,attachments,sessionId:job.scope.sessionId,signal:job.control.signal}):undefined
|
|
4434
|
+
return inspectMotionFilm({packDir:job.packDir,composition,signal:job.control.signal,reviewSequence,previousMotion:previous?.motion,expectedAudio:final&&!!composition.audio?.cues?.length})
|
|
4435
|
+
},
|
|
4436
|
+
notify: async (job, event) => {
|
|
4437
|
+
const content = [{ type: 'text', text: motionSessionNotice(job, event) }]
|
|
4438
|
+
const agent = ctx.get?.('agents')?.get?.(job.scope.sessionId)
|
|
4439
|
+
if (agent?.followup) { agent.followup({ id: randomUUID(), role: 'user', content, source: { kind: 'plugin:' + name } }); return }
|
|
4440
|
+
const controller = ctx.get?.('sessionController')
|
|
4441
|
+
if (!controller?.prompt) throw new Error('主会话服务不可用')
|
|
4442
|
+
await controller.prompt({ requestId: randomUUID(), sessionId: job.scope.sessionId, mode: 'queue', content }, new AbortController().signal)
|
|
4443
|
+
},
|
|
4444
|
+
commit: (job, output) => documents.run(job.scope.drawioAbs, async () => {
|
|
4445
|
+
const loaded = await loadTaskDocument(job)
|
|
4446
|
+
const current = loaded.doc.nodes.find((node) => node.id === job.nodeId)
|
|
4447
|
+
if (!current) throw new Error('目标已删除,成片保留在任务历史')
|
|
4448
|
+
const before = { data: JSON.parse(JSON.stringify(current.data)), style: current.style }
|
|
4449
|
+
applyMotionResult(loaded.doc, job, { ...output, style: stylePatch(current.style || '', {
|
|
4450
|
+
image: output.poster, imageAspect: '1', shape: 'image',
|
|
4451
|
+
}) })
|
|
4452
|
+
await writeMotionDocument(loaded, job.scope.sessionId, current.id, before)
|
|
4453
|
+
}),
|
|
4454
|
+
})
|
|
4455
|
+
async function remixMotion(scope,taskId,settings,prompt) {
|
|
4456
|
+
return documents.run(scope.drawioAbs,async()=>{
|
|
4457
|
+
const original=motionTasks.details(scope,taskId),loaded=await loadTaskDocument({scope:original.scope})
|
|
4458
|
+
const source=motionTasks.result(scope,taskId).job
|
|
4459
|
+
if(!['done','retained'].includes(source.status))throw new Error('请先完成视频制作,再修改声音')
|
|
4460
|
+
const node=assertMotionEditable(loaded.doc,source.nodeId)
|
|
4461
|
+
const before={data:JSON.parse(JSON.stringify(node.data)),style:node.style}
|
|
4462
|
+
if(settings)node.data.motion=mergeMotionSettings(node.data.motion,settings)
|
|
4463
|
+
if(prompt!==undefined)node.data.motion.audioPrompt=String(prompt).slice(0,5000)
|
|
4464
|
+
const input=motionInput(loaded.doc,node.id),nextScope={...original.scope,page:loaded.pageIndex}
|
|
4465
|
+
const job=await motionTasks.start(nextScope,node.id,input,{},undefined,async next=>{
|
|
4466
|
+
node.data.motion.requestId=next.id;node.data.motion.session={sessionId:scope.sessionId,requestId:next.session.requestId,taskId:next.id}
|
|
4467
|
+
await writeMotionDocument(loaded,scope.sessionId,node.id,before)
|
|
4468
|
+
},source.composition,{reuseJobId:taskId,audioOnly:true,requestId:randomUUID(),defer:true,silent:true})
|
|
4469
|
+
return motionTasks.accepted(nextScope,job.id)
|
|
4470
|
+
})
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
async function writeMotionDocument(loaded, sessionId, id, before) {
|
|
4474
|
+
const policy = policyFor(sessionId)
|
|
4475
|
+
if (policy?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
4476
|
+
const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex, format: 'drawai' })
|
|
4477
|
+
if (contentHash(await ctx.fs.readText(loaded.target)) !== contentHash(loaded.text)) throw new Error('画布已被其他编辑器修改,请重试')
|
|
4478
|
+
await ctx.fs.writeText(loaded.target, rendered.text, undefined, undefined, policy)
|
|
4479
|
+
const node = loaded.doc.nodes.find((item) => item.id === id)
|
|
4480
|
+
taskPatches.record(loaded.absolute, contentHash(loaded.text), rendered.revision, loaded.pageIndex, [], [], [],
|
|
4481
|
+
[{ id, before, after: { data: node.data, style: node.style } }])
|
|
4482
|
+
}
|
|
4483
|
+
async function resumeMotionTask(scope, id) {
|
|
4484
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
4485
|
+
return documents.run(scope.drawioAbs, () => motionTasks.resume(scope, id, async (job) => {
|
|
4486
|
+
const loaded = await loadTaskDocument(job)
|
|
4487
|
+
const node = assertMotionEditable(loaded.doc, job.nodeId)
|
|
4488
|
+
if (!isMotionNode(node)) throw new Error('请选择动效视频节点')
|
|
4489
|
+
if (motionInputKey(motionInput(loaded.doc, node.id)) !== job.inputKey) throw new Error('描述、参数或参考素材已变化,请恢复原设置后继续,或开始新任务')
|
|
4490
|
+
const before = { data: JSON.parse(JSON.stringify(node.data)), style: node.style }
|
|
4491
|
+
node.data.motion.requestId = job.id
|
|
4492
|
+
if (job.session) node.data.motion.session = { sessionId: scope.sessionId, requestId: job.session.requestId, taskId: job.id }
|
|
4493
|
+
await writeMotionDocument(loaded, scope.sessionId, node.id, before)
|
|
4494
|
+
}))
|
|
4495
|
+
}
|
|
4196
4496
|
const videoTracePool = createVideoTracePool()
|
|
4197
4497
|
const drawingTasks = createDrawingTaskManager({ pool: videoTracePool, slots: mediaSlots,
|
|
4198
4498
|
commit: commitDrawingTask, undo: undoDrawingTask,
|
|
@@ -4230,7 +4530,7 @@ function sanitizeNewName(raw) {
|
|
|
4230
4530
|
async function writeTaskDocument(job, loaded, node, removed = []) {
|
|
4231
4531
|
const policy = policyFor(job.scope.sessionId)
|
|
4232
4532
|
if (policy?.mode === 'read-only') throw new Error('当前会话为只读,绘制结果已保留')
|
|
4233
|
-
const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex })
|
|
4533
|
+
const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex, format: isNativeCanvasPath(loaded.absolute) ? 'drawai' : 'drawio' })
|
|
4234
4534
|
// The coordinator serializes plugin writers; a fresh check also detects
|
|
4235
4535
|
// external edits which happened while an asynchronous source check ran.
|
|
4236
4536
|
if (contentHash(await ctx.fs.readText(loaded.target)) !== contentHash(loaded.text)) throw new Error('文件已被其他编辑器修改,请重试加入结果')
|
|
@@ -4354,10 +4654,48 @@ function sanitizeNewName(raw) {
|
|
|
4354
4654
|
job.cancelled = true
|
|
4355
4655
|
wakeVideoTrace(job)
|
|
4356
4656
|
}
|
|
4357
|
-
videoTraceJobs.clear(); videoUploadJobs.clear()
|
|
4358
|
-
return Promise.all([drawingTasks.close(), videoExports.dispose()]).finally(() => videoTracePool.close())
|
|
4657
|
+
videoTraceJobs.clear(); videoUploadJobs.clear(); videoReadTickets.clear()
|
|
4658
|
+
return Promise.all([drawingTasks.close(), videoExports.dispose(), motionTasks.close()]).finally(() => videoTracePool.close())
|
|
4359
4659
|
})
|
|
4360
4660
|
async function handleSave(req, res) {
|
|
4661
|
+
if (req.method === 'GET') {
|
|
4662
|
+
const origin = req.headers.origin
|
|
4663
|
+
if (req.headers['sec-fetch-site'] === 'cross-site' || (origin && (() => { try { return new URL(origin).host !== req.headers.host } catch { return true } })())) {
|
|
4664
|
+
sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' }); return
|
|
4665
|
+
}
|
|
4666
|
+
let token
|
|
4667
|
+
try { token = new URL(req.url, 'http://localhost').searchParams.get('video') } catch { token = null }
|
|
4668
|
+
const ticket = videoReadTickets.get(token)
|
|
4669
|
+
if (!ticket || Date.now() - ticket.time > 3600000 || !sessionOf(ticket.sessionId)) {
|
|
4670
|
+
if (token) videoReadTickets.delete(token)
|
|
4671
|
+
sendJson(res, 403, { ok: false, error: 'video access expired' }); return
|
|
4672
|
+
}
|
|
4673
|
+
try {
|
|
4674
|
+
workspacePath({ sessionId: ticket.sessionId }, ticket.source)
|
|
4675
|
+
const info = await stat(ticket.source)
|
|
4676
|
+
if (!info.isFile() || !Number.isSafeInteger(info.size) || info.size < 1) throw new Error('video source unavailable')
|
|
4677
|
+
const total = info.size, range = req.headers.range
|
|
4678
|
+
let start = 0, end = total - 1
|
|
4679
|
+
if (range) {
|
|
4680
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range)
|
|
4681
|
+
if (!match || (!match[1] && !match[2])) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); res.end(); return }
|
|
4682
|
+
if (!match[1]) { const suffix = Number(match[2]); start = Math.max(0, total - suffix) }
|
|
4683
|
+
else { start = Number(match[1]); if (match[2]) end = Math.min(Number(match[2]), end) }
|
|
4684
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= total || end < start) {
|
|
4685
|
+
res.writeHead(416, { 'Content-Range': 'bytes */' + total }); res.end(); return
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
ticket.time = Date.now()
|
|
4689
|
+
const headers = { 'Content-Type': ticket.mime, 'Content-Length': end - start + 1, 'Accept-Ranges': 'bytes', 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff' }
|
|
4690
|
+
if (range) headers['Content-Range'] = `bytes ${start}-${end}/${total}`
|
|
4691
|
+
res.writeHead(range ? 206 : 200, headers)
|
|
4692
|
+
const stream = createReadStream(ticket.source, { start, end })
|
|
4693
|
+
stream.on('error', () => res.destroy())
|
|
4694
|
+
res.on('close', () => stream.destroy())
|
|
4695
|
+
stream.pipe(res)
|
|
4696
|
+
} catch (error) { sendJson(res, 404, { ok: false, error: 'video source unavailable' }) }
|
|
4697
|
+
return
|
|
4698
|
+
}
|
|
4361
4699
|
if (req.method !== 'POST') {
|
|
4362
4700
|
sendJson(res, 405, { ok: false, error: 'POST only' })
|
|
4363
4701
|
return
|
|
@@ -4401,14 +4739,14 @@ function sanitizeNewName(raw) {
|
|
|
4401
4739
|
}
|
|
4402
4740
|
const bodyLimit = body.action === 'render-result' ? RENDER_RESULT_MAX_BYTES
|
|
4403
4741
|
: body.action === 'trace-node-image' ? IMAGE_TRACE_MAX_BYTES
|
|
4404
|
-
: (body.action === undefined || body.action === 'create') ? CANVAS_SAVE_MAX_BYTES : SAVE_MAX_BYTES
|
|
4742
|
+
: (body.action === undefined || body.action === 'create' || body.action === 'motion-task-start') ? CANVAS_SAVE_MAX_BYTES : SAVE_MAX_BYTES
|
|
4405
4743
|
if (Buffer.byteLength(text, 'utf8') > bodyLimit) {
|
|
4406
4744
|
sendJson(res, 413, { ok: false, error: 'request body exceeds ' + bodyLimit + ' bytes' })
|
|
4407
4745
|
return
|
|
4408
4746
|
}
|
|
4409
4747
|
|
|
4410
4748
|
try {
|
|
4411
|
-
const lockAction = body.action === undefined || ['create', 'revert', 'media-cleanup', 'media-video-export-start'].includes(body.action)
|
|
4749
|
+
const lockAction = body.action === undefined || ['create', 'revert', 'media-cleanup', 'media-video-export-start', 'drawing-task-start', 'motion-task-start'].includes(body.action)
|
|
4412
4750
|
if (lockAction && typeof (body.path || body.name) === 'string' && workspaceRootOf(body.sessionId)) {
|
|
4413
4751
|
const target = await resolveTarget(body.path || body.name, body.sessionId)
|
|
4414
4752
|
return await documents.run(absoluteHint(target, body.path || body.name), () => dispatchSave(body, res))
|
|
@@ -4422,8 +4760,72 @@ function sanitizeNewName(raw) {
|
|
|
4422
4760
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
|
|
4423
4761
|
const rawPath = typeof body.path === 'string' ? body.path : undefined
|
|
4424
4762
|
|
|
4763
|
+
if (typeof body.action === 'string' && body.action.startsWith('motion-task-')) {
|
|
4764
|
+
if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !isNativeCanvasPath(rawPath)) throw new Error('动效生成需要当前工作区的 .drawai 画布')
|
|
4765
|
+
const target = await resolveTarget(rawPath, sessionId), root = workspaceRootOf(sessionId)
|
|
4766
|
+
if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new Error('画布路径超出工作区')
|
|
4767
|
+
const scope = { sessionId, drawioAbs: absoluteHint(target, rawPath), path: rawPath }
|
|
4768
|
+
let result
|
|
4769
|
+
if (body.action === 'motion-task-start') {
|
|
4770
|
+
if (policyFor(sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
4771
|
+
if (typeof body.requestId !== 'string' || !/^[a-f0-9-]{36}$/i.test(body.requestId)) throw new Error('视频请求缺少有效的请求 ID')
|
|
4772
|
+
const previous = motionTasks.findRequest(scope, body.requestId)
|
|
4773
|
+
if (previous) {
|
|
4774
|
+
if (previous.nodeId !== body.nodeId) throw new Error('请求 ID 已被另一个视频请求使用')
|
|
4775
|
+
sendJson(res, 200, { ok: true, job: previous }); return
|
|
4776
|
+
}
|
|
4777
|
+
const loaded = await loadDoc(rawPath, sessionId, body.page)
|
|
4778
|
+
if (!loaded.exists || loaded.doc.revision !== body.revision) throw new Error('画布已更新,请等待同步后重新生成')
|
|
4779
|
+
const node = loaded.doc.nodes.find((item) => item.id === body.nodeId)
|
|
4780
|
+
if (!isMotionNode(node)) throw new Error('请选择动效视频节点')
|
|
4781
|
+
const input = motionInput(loaded.doc, node.id)
|
|
4782
|
+
// Lock checks are also repeated at delivery, after potentially long renders.
|
|
4783
|
+
assertMotionEditable(loaded.doc, node.id)
|
|
4784
|
+
const before = { data: JSON.parse(JSON.stringify(node.data)), style: node.style }
|
|
4785
|
+
Object.assign(scope, { page: loaded.pageIndex, pageId: loaded.pageId, pageNames: loaded.pageNames })
|
|
4786
|
+
const job = await motionTasks.start(scope, node.id, input, body.previews || {}, body.composition, async (job) => {
|
|
4787
|
+
node.data.motion.requestId = job.id
|
|
4788
|
+
node.data.motion.session = { sessionId, requestId: job.session.requestId, taskId: job.id }
|
|
4789
|
+
await writeMotionDocument(loaded, sessionId, node.id, before)
|
|
4790
|
+
}, node.data.motion.composition, { defer: true, requestId: body.requestId, silent: true, projectFromJobId:node.data.motion.lastJobId })
|
|
4791
|
+
result = { job: await motionTasks.accepted(scope, job.id) }
|
|
4792
|
+
} else if(body.action==='motion-task-remix') {
|
|
4793
|
+
if(policyFor(sessionId)?.mode==='read-only')throw new Error('当前会话为只读')
|
|
4794
|
+
result={job:await remixMotion(scope,body.taskId)}
|
|
4795
|
+
} else if (body.action === 'motion-task-list') result = { jobs: motionTasks.list(scope), focus: motionFocus.get(sessionId + '\n' + scope.drawioAbs) || null }
|
|
4796
|
+
else if (body.action === 'motion-task-result') result = motionTasks.result(scope, body.taskId)
|
|
4797
|
+
else if (body.action === 'motion-task-review-film') {
|
|
4798
|
+
if(policyFor(sessionId)?.mode==='read-only')throw new Error('当前会话为只读')
|
|
4799
|
+
result={job:motionTasks.reviewFilm(scope,body.taskId,body.review||{})}
|
|
4800
|
+
}
|
|
4801
|
+
else if (body.action === 'motion-task-cancel') result = { job: await motionTasks.cancel(scope, body.taskId) }
|
|
4802
|
+
else if (body.action === 'motion-task-resume') result = { job: await resumeMotionTask(scope, body.taskId) }
|
|
4803
|
+
else if (body.action === 'motion-task-remove') {
|
|
4804
|
+
if (policyFor(sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
4805
|
+
result = { job: motionTasks.remove(scope, body.taskId) }
|
|
4806
|
+
}
|
|
4807
|
+
else if (body.action === 'motion-task-apply') {
|
|
4808
|
+
if (policyFor(sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
4809
|
+
result = { job: await motionTasks.apply(scope, body.taskId) }
|
|
4810
|
+
} else throw new Error('未知动效任务操作')
|
|
4811
|
+
sendJson(res, 200, { ok: true, ...result }); return
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
if (typeof body.action === 'string' && body.action.startsWith('audio-')) {
|
|
4815
|
+
if(!sessionOf(sessionId)||!workspaceRootOf(sessionId)||!isNativeCanvasPath(rawPath))throw new Error('音频操作需要当前工作区画布')
|
|
4816
|
+
const target=await resolveTarget(rawPath,sessionId)
|
|
4817
|
+
if(!ctx.fs.contains(await ctx.fs.resolve(workspaceRootOf(sessionId)),target))throw new Error('画布路径超出工作区')
|
|
4818
|
+
const scope={sessionId,drawioAbs:absoluteHint(target,rawPath),path:rawPath}
|
|
4819
|
+
if(body.action!=='audio-read'&&policyFor(sessionId)?.mode==='read-only')throw new Error('当前会话为只读')
|
|
4820
|
+
const methods={'audio-search':'search','audio-get':'get','audio-upload-start':'start','audio-upload-chunk':'chunk','audio-upload-finish':'finish','audio-upload-release':'release'}
|
|
4821
|
+
let result
|
|
4822
|
+
if(body.action==='audio-read')result=audioService.read(scope,body.clip)
|
|
4823
|
+
else {const method=methods[body.action];if(!method)throw new Error('未知音频操作');result=await audioService[method](scope,body)}
|
|
4824
|
+
sendJson(res,200,{ok:true,...result});return
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4425
4827
|
if (typeof body.action === 'string' && body.action.startsWith('drawing-task-')) {
|
|
4426
|
-
if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !rawPath
|
|
4828
|
+
if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !isCanvasPath(rawPath)) throw new Error('绘制任务需要当前工作区画布')
|
|
4427
4829
|
const target = await resolveTarget(rawPath, sessionId), root = workspaceRootOf(sessionId)
|
|
4428
4830
|
if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new Error('画布路径超出工作区')
|
|
4429
4831
|
const scope = { sessionId, path: rawPath, drawioAbs: absoluteHint(target, rawPath), workspaceRoot: root }
|
|
@@ -4483,6 +4885,12 @@ function sanitizeNewName(raw) {
|
|
|
4483
4885
|
return
|
|
4484
4886
|
}
|
|
4485
4887
|
|
|
4888
|
+
// 文生图暂时关闭:旧客户端也不能发起生成、探测或工作流请求。
|
|
4889
|
+
if (['imagegen-generate', 'imagegen-status', 'imagegen-default-workflow'].includes(body.action)) {
|
|
4890
|
+
sendJson(res, 503, { ok: false, error: '文生图功能暂未开放' })
|
|
4891
|
+
return
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4486
4894
|
// action: 'list' —— 列出工作区里可打开的 .drawio,供「打开」菜单用。
|
|
4487
4895
|
// 复用同一条路由而不是另开一个:信任围栏(POST + 自定义头 + 同源)只写一次,
|
|
4488
4896
|
// 少一个口子就少一处可能忘记校验的地方。
|
|
@@ -4593,7 +5001,33 @@ function sanitizeNewName(raw) {
|
|
|
4593
5001
|
sendJson(res, 200, { ok: true, exists: false, path: rawPath, absolute: absoluteHint(readTarget, rawPath), revision: '', doc: emptyDoc(), notes: [] })
|
|
4594
5002
|
return
|
|
4595
5003
|
}
|
|
4596
|
-
|
|
5004
|
+
let openPath = rawPath
|
|
5005
|
+
let migratedTo
|
|
5006
|
+
if (body.autoMigrate === true && policyFor(sessionId)?.mode !== 'read-only' && /\.drawio$/i.test(rawPath)) {
|
|
5007
|
+
const sourceAbsolute = absoluteHint(readTarget, rawPath)
|
|
5008
|
+
const destination = await resolveTarget(nativeCanvasPath(rawPath), sessionId)
|
|
5009
|
+
const rootTarget = await ctx.fs.resolve(readRoot)
|
|
5010
|
+
if (!ctx.fs.contains(rootTarget, destination)) throw new Error('迁移目标超出工作区')
|
|
5011
|
+
const destinationAbsolute = absoluteHint(destination, nativeCanvasPath(rawPath))
|
|
5012
|
+
const scope = { sessionId, workspaceRoot: readRoot, path: rawPath, drawioAbs: sourceAbsolute }
|
|
5013
|
+
await documents.run(sourceAbsolute, () => documents.run(destinationAbsolute, async () => {
|
|
5014
|
+
if (existsSync(sourceAbsolute)) {
|
|
5015
|
+
if (!drawingTasks.canMigrateCanvas(scope, sourceAbsolute) || !videoExports.canMigrateCanvas(scope)) {
|
|
5016
|
+
return
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
await migrateCanvasFile({ fs: ctx.fs, source: readTarget, destination,
|
|
5020
|
+
sourcePath: sourceAbsolute, destinationPath: destinationAbsolute, policy: policyFor(sessionId) })
|
|
5021
|
+
if (existsSync(sourceAbsolute)) {
|
|
5022
|
+
drawingTasks.migrateCanvas(scope, sourceAbsolute, destinationAbsolute)
|
|
5023
|
+
videoExports.migrateCanvas(scope, destinationAbsolute)
|
|
5024
|
+
}
|
|
5025
|
+
openPath = nativeCanvasPath(rawPath)
|
|
5026
|
+
migratedTo = destinationAbsolute
|
|
5027
|
+
}))
|
|
5028
|
+
}
|
|
5029
|
+
const loaded = await loadDoc(openPath, sessionId, body.page)
|
|
5030
|
+
if (body.autoMigrate && /\.drawio$/i.test(openPath)) loaded.notes.push('本次因只读模式或后台任务继续使用旧格式;任务结束后重新打开可迁移')
|
|
4597
5031
|
// 两件"顺路带给客户端"的东西:
|
|
4598
5032
|
// highlight —— AI 用 `{op:'highlight', ids}` 请求的高亮,**取走即清**(一次性);
|
|
4599
5033
|
// canRevert —— 这张画布上有没有可退回的 AI 改动(菜单项据此启用)。
|
|
@@ -4629,6 +5063,7 @@ function sanitizeNewName(raw) {
|
|
|
4629
5063
|
exists: true,
|
|
4630
5064
|
path: rawPath,
|
|
4631
5065
|
absolute: loaded.absolute,
|
|
5066
|
+
...(migratedTo ? { migratedTo } : {}),
|
|
4632
5067
|
revision: loaded.doc.revision,
|
|
4633
5068
|
doc: loaded.doc,
|
|
4634
5069
|
notes: loaded.notes,
|
|
@@ -4981,7 +5416,7 @@ function sanitizeNewName(raw) {
|
|
|
4981
5416
|
}
|
|
4982
5417
|
}
|
|
4983
5418
|
// 空路径(画布没绑定文件)或空选区 = 清掉记录:别让上一次的选区继续跟着走。
|
|
4984
|
-
if (selRaw.length === 0 || selIds.length === 0 || selRaw
|
|
5419
|
+
if (selRaw.length === 0 || selIds.length === 0 || !isCanvasPath(selRaw)) {
|
|
4985
5420
|
canvasSelection.delete(sessionId)
|
|
4986
5421
|
sendJson(res, 200, { ok: true, count: 0 })
|
|
4987
5422
|
return
|
|
@@ -5051,7 +5486,7 @@ function sanitizeNewName(raw) {
|
|
|
5051
5486
|
sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
|
|
5052
5487
|
return
|
|
5053
5488
|
}
|
|
5054
|
-
if (!
|
|
5489
|
+
if (!isCanvasPath(rawPath)) {
|
|
5055
5490
|
sendJson(res, 403, { ok: false, error: 'media actions require a .drawio path' })
|
|
5056
5491
|
return
|
|
5057
5492
|
}
|
|
@@ -5096,8 +5531,8 @@ function sanitizeNewName(raw) {
|
|
|
5096
5531
|
}
|
|
5097
5532
|
if (body.action === 'media-upload-start') {
|
|
5098
5533
|
const size = Number(body.size), w = Number(body.w), h = Number(body.h)
|
|
5099
|
-
if (!['mp4', 'webm', 'mov', 'mkv'].includes(body.extension) || !Number.
|
|
5100
|
-
!Number.isInteger(w) || !Number.isInteger(h) || Math.min(w, h) <= 0 || Math.max(w, h) > 16384) throw new Error('
|
|
5534
|
+
if (!['mp4', 'webm', 'mov', 'mkv'].includes(body.extension) || !Number.isSafeInteger(size) || size <= 0 ||
|
|
5535
|
+
!Number.isInteger(w) || !Number.isInteger(h) || Math.min(w, h) <= 0 || Math.max(w, h) > 16384) throw new Error('无效视频文件:须为非空的 MP4/WebM/MOV/MKV')
|
|
5101
5536
|
for (const [key, job] of videoUploadJobs) if (Date.now() - job.time > 3600000) videoUploadJobs.delete(key)
|
|
5102
5537
|
if (videoUploadJobs.size >= 16) throw new Error('导入任务过多,请稍后重试')
|
|
5103
5538
|
const token = randomUUID(), newClip = 'video-' + randomUUID()
|
|
@@ -5407,7 +5842,7 @@ function sanitizeNewName(raw) {
|
|
|
5407
5842
|
sendJson(res, 200, { ok: true, source: imported.rel, manifest: imported.manifest })
|
|
5408
5843
|
return
|
|
5409
5844
|
}
|
|
5410
|
-
if (body.action === 'media-source-bytes') {
|
|
5845
|
+
if (body.action === 'media-source-url' || body.action === 'media-source-bytes') {
|
|
5411
5846
|
if (clip === null) {
|
|
5412
5847
|
sendJson(res, 400, { ok: false, error: 'clip is required' })
|
|
5413
5848
|
return
|
|
@@ -5419,16 +5854,20 @@ function sanitizeNewName(raw) {
|
|
|
5419
5854
|
sendJson(res, 404, { ok: false, error: 'no source video in clip' })
|
|
5420
5855
|
return
|
|
5421
5856
|
}
|
|
5422
|
-
const buf = await readFile(source.abs)
|
|
5423
|
-
// 上限约 48MB:更大的片请用外部播放器;JSON base64 约膨胀 33%。
|
|
5424
|
-
if (buf.length > 48 * 1024 * 1024) {
|
|
5425
|
-
sendJson(res, 413, { ok: false, error: 'source too large for inline transfer (>48MB)' })
|
|
5426
|
-
return
|
|
5427
|
-
}
|
|
5428
5857
|
const ext = source.rel.toLowerCase().split('.').pop()
|
|
5429
5858
|
const mime =
|
|
5430
5859
|
ext === 'mp4' ? 'video/mp4' : ext === 'webm' ? 'video/webm' : ext === 'mov' ? 'video/quicktime' : 'application/octet-stream'
|
|
5431
|
-
|
|
5860
|
+
if (body.action === 'media-source-url') {
|
|
5861
|
+
workspacePath({ sessionId }, source.abs)
|
|
5862
|
+
for (const [key, ticket] of videoReadTickets) if (Date.now() - ticket.time > 3600000) videoReadTickets.delete(key)
|
|
5863
|
+
const token = randomUUID() + randomUUID()
|
|
5864
|
+
videoReadTickets.set(token, { sessionId, source: source.abs, mime, time: Date.now() })
|
|
5865
|
+
sendJson(res, 200, { ok: true, url: SAVE_PATH + '?video=' + token, mime })
|
|
5866
|
+
} else {
|
|
5867
|
+
// 兼容旧客户端;新客户端使用可分段读取的 URL,不再整片转 base64。
|
|
5868
|
+
const buf = await readFile(source.abs)
|
|
5869
|
+
sendJson(res, 200, { ok: true, rel: source.rel, mime, base64: buf.toString('base64') })
|
|
5870
|
+
}
|
|
5432
5871
|
return
|
|
5433
5872
|
}
|
|
5434
5873
|
if (body.action === 'media-pack-bundle') {
|
|
@@ -5436,7 +5875,7 @@ function sanitizeNewName(raw) {
|
|
|
5436
5875
|
const outName =
|
|
5437
5876
|
typeof body.out === 'string' && body.out.length > 0
|
|
5438
5877
|
? body.out
|
|
5439
|
-
: basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '.drawai.zip'
|
|
5878
|
+
: basenamePath(drawioAbs).replace(/\.(?:drawio|drawai)$/i, '') + '.drawai.zip'
|
|
5440
5879
|
const safe = String(outName).replace(/^[\\/]+/, '')
|
|
5441
5880
|
if (safe.indexOf('..') >= 0) {
|
|
5442
5881
|
sendJson(res, 400, { ok: false, error: 'invalid out path' })
|
|
@@ -5472,7 +5911,7 @@ function sanitizeNewName(raw) {
|
|
|
5472
5911
|
const outName =
|
|
5473
5912
|
typeof body.out === 'string' && body.out.length > 0
|
|
5474
5913
|
? body.out
|
|
5475
|
-
: basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '-' + clip + '.svgvideo.zip'
|
|
5914
|
+
: basenamePath(drawioAbs).replace(/\.(?:drawio|drawai)$/i, '') + '-' + clip + '.svgvideo.zip'
|
|
5476
5915
|
const safe = String(outName).replace(/^[\\/]+/, '')
|
|
5477
5916
|
if (safe.indexOf('..') >= 0) {
|
|
5478
5917
|
sendJson(res, 400, { ok: false, error: 'invalid out path' })
|
|
@@ -5519,8 +5958,9 @@ function sanitizeNewName(raw) {
|
|
|
5519
5958
|
const parsed = page === 0 ? current : await loadDoc(drawioAbs, sessionId, page)
|
|
5520
5959
|
nodes.push(...parsed.doc.nodes)
|
|
5521
5960
|
}
|
|
5522
|
-
const leases = new Set([...drawingTasks.leases(drawioAbs, { workspaceRoot: mediaRoot }), ...videoExports.leases(drawioAbs)])
|
|
5961
|
+
const leases = new Set([...drawingTasks.leases(drawioAbs, { workspaceRoot: mediaRoot }), ...videoExports.leases(drawioAbs), ...motionTasks.leases({ sessionId, drawioAbs })])
|
|
5523
5962
|
for (const job of videoTraceJobs.values()) if (job.drawioAbs === drawioAbs) leases.add(job.clip)
|
|
5963
|
+
for (const clip of audioService.leases({drawioAbs})) leases.add(clip)
|
|
5524
5964
|
const removed = cleanupUnreferencedClips(drawioAbs, { nodes }, leases)
|
|
5525
5965
|
sendJson(res, 200, { ok: true, removed: removed })
|
|
5526
5966
|
return
|
|
@@ -5532,7 +5972,7 @@ function sanitizeNewName(raw) {
|
|
|
5532
5972
|
}
|
|
5533
5973
|
const dir = clipDirFor(drawioAbs, clip)
|
|
5534
5974
|
const manifest = readManifest(dir)
|
|
5535
|
-
const source = findSourceFile(dir, manifest)
|
|
5975
|
+
const source = body.variant && body.variant !== 'full' ? (()=>{const name={picture:'picture.mp4',noBgm:'no-bgm.mp4'}[body.variant];if(!name||!existsSync(joinPath(dir,name)))throw new Error('此版本不存在');return {rel:name,abs:joinPath(dir,name)}})() : findSourceFile(dir, manifest)
|
|
5536
5976
|
if (source === null) {
|
|
5537
5977
|
sendJson(res, 404, { ok: false, error: 'no source video' })
|
|
5538
5978
|
return
|
|
@@ -5540,7 +5980,7 @@ function sanitizeNewName(raw) {
|
|
|
5540
5980
|
const outName =
|
|
5541
5981
|
typeof body.out === 'string' && body.out.length > 0
|
|
5542
5982
|
? body.out
|
|
5543
|
-
: basenamePath(drawioAbs).replace(/\.drawio$/i, '') + '-' + clip + '-' + source.rel
|
|
5983
|
+
: basenamePath(drawioAbs).replace(/\.(?:drawio|drawai)$/i, '') + '-' + clip + '-' + source.rel
|
|
5544
5984
|
const safe = String(outName).replace(/^[\\/]+/, '')
|
|
5545
5985
|
if (safe.indexOf('..') >= 0) {
|
|
5546
5986
|
sendJson(res, 400, { ok: false, error: 'invalid out path' })
|
|
@@ -5612,7 +6052,7 @@ function sanitizeNewName(raw) {
|
|
|
5612
6052
|
// `{version, meta, nodes, edges}`:这就是同一个"逐个字段抄"的毛病,
|
|
5613
6053
|
// 抄漏 layers 之后内存里的画布与盘上的文件对不上。
|
|
5614
6054
|
const blankDoc = Object.assign({}, emptyDoc(), { meta: { pinned: true } })
|
|
5615
|
-
const blankBuilt = buildMxfile(blankDoc, { name: nameCheck.name.replace(/\.drawio$/i, '') })
|
|
6055
|
+
const blankBuilt = buildMxfile(blankDoc, { name: nameCheck.name.replace(/\.(?:drawio|drawai)$/i, ''), format: isNativeCanvasPath(nameCheck.name) ? 'drawai' : 'drawio' })
|
|
5616
6056
|
const createPolicy = policyFor(sessionId)
|
|
5617
6057
|
try {
|
|
5618
6058
|
if (createPolicy === undefined) await ctx.fs.writeText(createTarget, blankBuilt.text)
|
|
@@ -5635,7 +6075,7 @@ function sanitizeNewName(raw) {
|
|
|
5635
6075
|
sendJson(res, 400, { ok: false, error: 'sessionId and path are required' })
|
|
5636
6076
|
return
|
|
5637
6077
|
}
|
|
5638
|
-
if (!rawPath
|
|
6078
|
+
if (!isCanvasPath(rawPath)) {
|
|
5639
6079
|
sendJson(res, 403, { ok: false, error: 'only .drawio documents may be written' })
|
|
5640
6080
|
return
|
|
5641
6081
|
}
|
|
@@ -5707,9 +6147,19 @@ function sanitizeNewName(raw) {
|
|
|
5707
6147
|
next.meta = Object.assign({}, next.meta, { pinned: true })
|
|
5708
6148
|
let rendered
|
|
5709
6149
|
try {
|
|
5710
|
-
|
|
6150
|
+
let template = exists ? originalText : undefined
|
|
6151
|
+
if (!exists && body.createOnly === true && typeof body.fromPath === 'string') {
|
|
6152
|
+
const source = await loadDoc(body.fromPath, sessionId, body.page)
|
|
6153
|
+
if (source.exists) template = source.text
|
|
6154
|
+
}
|
|
6155
|
+
rendered = renderDocText(template, next, {
|
|
5711
6156
|
page: Number.isFinite(Number(body.page)) ? Number(body.page) : 0,
|
|
6157
|
+
format: isNativeCanvasPath(rawPath) ? 'drawai' : 'drawio',
|
|
5712
6158
|
})
|
|
6159
|
+
if (body.createOnly === true && body.fromPath && isNativeCanvasPath(rawPath)) {
|
|
6160
|
+
rendered.text = forkNativeCanvas(rendered.text)
|
|
6161
|
+
rendered.revision = contentHash(rendered.text)
|
|
6162
|
+
}
|
|
5713
6163
|
} catch (error) {
|
|
5714
6164
|
sendJson(res, 400, { ok: false, error: '写入前解析原文件失败:' + messageOf(error) })
|
|
5715
6165
|
return
|
|
@@ -5737,12 +6187,1191 @@ function sanitizeNewName(raw) {
|
|
|
5737
6187
|
|
|
5738
6188
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: handleSave }), 'drawai: ' + SAVE_PATH)
|
|
5739
6189
|
|
|
6190
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6191
|
+
name: 'shotcraft_search',
|
|
6192
|
+
description: '搜索 DrawAI 内置的 124 张 Shotcraft 镜头卡 / 150 个准确样式。按关键词(中英文卡名、样式、用途)和分类检索;返回唯一 demo、样片及可用于 diagram_apply 的节点参数。先搜索再添加 shape:shotcard 节点并连到视频节点,支持同时参考多卡。',
|
|
6193
|
+
parameters: { query: { type: 'string' }, category: { type: 'string' }, cardId: { type: 'string', description: '精确卡名,查看该卡所有样式;配合 styleKey 会直接返回这张卡的配方与 demo 源码' },
|
|
6194
|
+
styleKey: { type: 'string', description: '精确样式 key(与 cardId 一起用,等于"读这张卡")' },
|
|
6195
|
+
offset: { type: 'number' }, limit: { type: 'number', description: '每页 1–30,默认 12' } },
|
|
6196
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6197
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6198
|
+
async execute(args) {
|
|
6199
|
+
const matches = searchShotCards(shotCardCatalog, args.query, args.category).filter((item) => !args.cardId || item.cardId === args.cardId)
|
|
6200
|
+
const offset = Math.max(0, Math.floor(Number(args.offset) || 0)), limit = Math.min(30, Math.max(1, Math.floor(Number(args.limit) || 12)))
|
|
6201
|
+
// 读一张卡:配方(怎么实现、参数、坑)+ demo 源码(照着改)。
|
|
6202
|
+
// 这是"按卡做"和"按卡名做"的分水岭——不读这一步,卡就只是个标签。
|
|
6203
|
+
if (args.cardId && args.styleKey) {
|
|
6204
|
+
const match = matches.find((item) => item.styleKey === args.styleKey)
|
|
6205
|
+
if (!match) throw new Error('没有这个样式:' + args.cardId + ' / ' + args.styleKey)
|
|
6206
|
+
const contract = motionCardContract({ cardId: match.cardId, styleKey: match.styleKey })
|
|
6207
|
+
return { summary: '已读镜头卡 ' + match.label,
|
|
6208
|
+
details: JSON.stringify({ cardId: contract.cardId, styleKey: contract.styleKey, recipePath: contract.cardSource, demoPath: match.demo,
|
|
6209
|
+
duration: match.duration, energy: match.energy, recipe: contract.recipe, demoSource: contract.source, preservation: contract.preservation }) }
|
|
6210
|
+
}
|
|
6211
|
+
const withPath = (item) => ({ ...item, recipe: shotCardCatalog.cards.find((card) => card.id === item.cardId)?.source })
|
|
6212
|
+
return { summary: '找到 ' + matches.length + ' 个镜头样式', details: JSON.stringify({ total: matches.length, offset, categories: shotCardCatalog.categories,
|
|
6213
|
+
hint: '写场景前先用 cardId+styleKey 再搜一次,把配方和 demo 源码读回来',
|
|
6214
|
+
styles: matches.slice(offset, offset + limit).map((item) => ({ ...withPath(item), node: { op: 'addNode', shape: 'shotcard', cardId: item.cardId, styleKey: item.styleKey } })) }) }
|
|
6215
|
+
},
|
|
6216
|
+
})))
|
|
6217
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6218
|
+
name: 'mg_style',
|
|
6219
|
+
description: 'DrawAI 内置的 14 种整片 MG 动效风格(mg-styles 库):扁平矢量 / 线条动画 / 等轴 2.5D / 逐帧手绘 / 拼贴 / 液态 / 形变 / 包豪斯 / 80s 复古 / 玻璃拟态 / 综艺花字 / 粗描边贴纸科普 / 像素 / 赛博 HUD。上游那支 04-3d-render(C4D/Blender 质感的 CGI 软着陆)没有收录:画面是 Blender 离线渲的,插件里没有同等质感的等价物——用 CSS 滤镜凑出来的 CGI 只会更假,需要那种质感就外部渲好当素材回灌(motion_assets capture / 参考段)。这是「整片风格」(一种片子的整体视觉语言),与 shotcraft_search 的「单镜动效卡」互补、可同时用。action:"list" 列出全部;"search" 按关键词检索;"read" 读某个风格的施工包(可检验判据 + 色板 + 字体 + 节奏 + 效果配方 + 上游提示词 + Remotion 移植 brief);"source" 读上游成片源码正文;**"frames" 从上游 demo 按 cue 时间点抽参考帧**——动态、转场、冲击、颗粒这些只有看帧才知道,写场景前先取。选中后 diagram_apply 加 shape:"mgstyle" 节点并连到视频节点,motion_project 会把风格写进工程主题与 STYLE.md。',
|
|
6220
|
+
parameters: { action: { type: 'string', enum: ['list', 'search', 'read', 'source', 'frames'], required: true },
|
|
6221
|
+
slug: { type: 'string', description: '风格 slug,如 09-bauhaus' },
|
|
6222
|
+
query: { type: 'string', description: 'search:关键词(中英文风格名、用途、视觉特征)' },
|
|
6223
|
+
limit: { type: 'number', description: 'list/search 返回条数,默认 15/8' },
|
|
6224
|
+
files: { type: 'array', items: { type: 'string' }, description: 'source:上游源码相对路径(如 demos/09-bauhaus/film.js);不传则列出该风格全部文件' },
|
|
6225
|
+
times: { type: 'array', items: { type: 'number' }, description: 'frames:指定时间点(秒),不传则按 cues.json 均匀抽 8 帧' },
|
|
6226
|
+
count: { type: 'number', description: 'frames:默认抽几帧,8' } },
|
|
6227
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6228
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6229
|
+
async execute(args) {
|
|
6230
|
+
const action = args.action || 'list'
|
|
6231
|
+
const pack = (style) => ({ slug: style.slug, name: style.name, en: style.en, oneLiner: style.oneLiner,
|
|
6232
|
+
tags: style.tags, signature: style.signature, palette: style.palette, fonts: style.fonts, rhythm: style.rhythm,
|
|
6233
|
+
engine: style.engine, engines: style.engines || [], engineNote: style.engineNote || '',
|
|
6234
|
+
motion: style.motion || null,
|
|
6235
|
+
aspect: style.rhythm?.aspect || '16:9', bpm: style.rhythm?.bpm ?? null,
|
|
6236
|
+
node: { op: 'addNode', shape: 'mgstyle', slug: style.slug } })
|
|
6237
|
+
if (action === 'list' || action === 'search') {
|
|
6238
|
+
const limit = Math.min(20, Math.max(1, Math.floor(Number(args.limit) || (action === 'list' ? 15 : 8))))
|
|
6239
|
+
const matches = action === 'list' ? mgStyleCatalog.styles.map(pack) : searchMgStyles(mgStyleCatalog, args.query).map(pack)
|
|
6240
|
+
return { summary: action === 'list' ? '共 ' + matches.length + ' 种整片风格(' + mgStyleCatalog.commit.slice(0, 8) + ')'
|
|
6241
|
+
: '找到 ' + matches.length + ' 种风格',
|
|
6242
|
+
details: JSON.stringify({ total: matches.length, commit: mgStyleCatalog.commit, license: mgStyleCatalog.license,
|
|
6243
|
+
hint: '选一个 slug 用 action:"read" 读施工包(brief 是移植说明书);再 diagram_apply 加 shape:"mgstyle" 节点连到视频节点。一条片子只能选一种整片风格。',
|
|
6244
|
+
styles: matches.slice(0, limit) }) }
|
|
6245
|
+
}
|
|
6246
|
+
if (!args.slug) throw new Error('read/source/frames 需要 slug;可以先用 action:"list" 看全部')
|
|
6247
|
+
const style = resolveMgStyle(mgStyleCatalog, args.slug)
|
|
6248
|
+
if (action === 'frames') {
|
|
6249
|
+
await ensureMotionAssets('抽风格参考帧')
|
|
6250
|
+
const captured = await captureStyleFrames({ slug: style.slug, times: args.times, count: args.count })
|
|
6251
|
+
return { summary: '已取 ' + captured.files.length + ' 张参考帧:' + captured.slug + '(' + captured.width + '×' + captured.height + ' @' + captured.fps + 'fps)',
|
|
6252
|
+
details: JSON.stringify({ ...captured,
|
|
6253
|
+
hint: '用 read_image 逐张看,再按 STYLE.md 的「效果配方」逐项对照写场景:转场是擦还是切、落地有没有冲击环/相机抖、运动模糊几采样、颗粒多粗、有没有二次运动。参考帧是拿来看效果的,不是逐像素复刻。' }) }
|
|
6254
|
+
}
|
|
6255
|
+
if (action === 'read') {
|
|
6256
|
+
const kit = mgStyleKit({ slug: style.slug })
|
|
6257
|
+
return { summary: '已读风格 ' + kit.name + '(' + kit.slug + ')',
|
|
6258
|
+
details: JSON.stringify({ slug: kit.slug, name: kit.name, en: kit.en, oneLiner: kit.oneLiner,
|
|
6259
|
+
signature: kit.signature, palette: kit.palette, colors: kit.colors, fonts: kit.fonts, rhythm: kit.rhythm,
|
|
6260
|
+
engine: kit.engine, engines: kit.engines, engineNote: kit.engineNote, engineFiles: kit.engineFiles,
|
|
6261
|
+
motion: kit.motion,
|
|
6262
|
+
promptPath: kit.prompt, briefPath: kit.brief, briefSha256: kit.briefSha256,
|
|
6263
|
+
sources: mgStyleSources(kit.slug).map((doc) => ({ path: doc.path, chars: doc.chars })),
|
|
6264
|
+
frames: { action: 'frames', slug: kit.slug, hint: '按 cue 抽 8 帧参考图:动态/转场/冲击/颗粒看这套,比读 brief 准' },
|
|
6265
|
+
hint: '要更细的实现细节用 action:"source" files:["…"] 读上游正文;brief 已经提炼过,先信 brief。引擎按 engines 里的来:three/webgl2 不许用 CSS 或 Canvas2D 简化替代,渲染前会扫工程源码拦截。**写场景前先 action:"frames" 取参考帧看动态**,只读 brief 会做成"形似而动不像"。',
|
|
6266
|
+
brief: kit.brief, prompt: kit.promptSource, preservation: kit.preservation }) }
|
|
6267
|
+
}
|
|
6268
|
+
if (action === 'source') {
|
|
6269
|
+
const files = Array.isArray(args.files) ? args.files.filter((item) => typeof item === 'string' && item) : []
|
|
6270
|
+
const available = style.demos.map((path) => ({ path, bytes: mgStyleFileSize(path) }))
|
|
6271
|
+
if (!files.length) return { summary: style.slug + ' 有 ' + available.length + ' 个上游文件', details: JSON.stringify({ slug: style.slug, files: available }) }
|
|
6272
|
+
const documents = files.map((path) => {
|
|
6273
|
+
if (!path.startsWith('demos/' + style.slug + '/') && path !== style.prompt) throw new Error('这个文件不属于 ' + style.slug + ':' + path)
|
|
6274
|
+
const source = mgStyleFile(path)
|
|
6275
|
+
return { path, chars: source.length, source: source.length > 120000 ? source.slice(0, 120000) + '\n/* …截断 ' + (source.length - 120000) + ' 字符… */' : source }
|
|
6276
|
+
})
|
|
6277
|
+
return { summary: '已读 ' + documents.length + ' 个上游文件', details: JSON.stringify({ slug: style.slug, documents }) }
|
|
6278
|
+
}
|
|
6279
|
+
throw new Error('未知 action:' + action)
|
|
6280
|
+
},
|
|
6281
|
+
})))
|
|
6282
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6283
|
+
name:'audio_asset',
|
|
6284
|
+
description:'为画布搜索和导入音频。search 从 Freesound 搜索;get 按 fs:数字 下载并创建音频节点;import 从当前工作区导入 mp3/wav/m4a/aac/ogg/flac/webm,32MB/10分钟以内;targetId 可自动连接视频。configure 修改音频节点的 audioRole(sfx/bgm/voice)、audioGainDb、audioTrimStart/End(秒,End=0至末尾)、audioAt(全片秒)、audioFadeIn/Out(秒)、audioLoop(字符串0/1)。inspect 返回素材来源、哈希和检测结果。音频需要连接到视频;motion_task settings.audioMode=auto让AI安排镜头声音,manual使用节点参数或audioPlan,off静音;remix只重混已有成片。没有真实试听不得宣称听感通过。',
|
|
6285
|
+
parameters:{action:{type:'string',enum:['search','get','import','configure','inspect'],required:true},path:{type:'string'},page:{type:'number'},query:{type:'string'},limit:{type:'number'},id:{type:'string'},file:{type:'string'},nodeId:{type:'string'},targetId:{type:'string'},role:{type:'string',enum:['sfx','bgm','voice']},settings:{type:'json'},x:{type:'number'},y:{type:'number'}},
|
|
6286
|
+
output:{schema:{type:'object',additionalProperties:false,properties:{summary:{type:'string',required:true},details:{type:'string',required:true}}},render:(_args,value)=>[{type:'text',text:value.summary+'\n'+value.details}]},
|
|
6287
|
+
async execute(args,exec){
|
|
6288
|
+
const sessionId=sessionIdOf(exec),path=args.path||focusedPathFor(sessionId)
|
|
6289
|
+
if(!sessionOf(sessionId)||!workspaceRootOf(sessionId)||!isNativeCanvasPath(path))throw new Error('请选择当前工作区画布')
|
|
6290
|
+
const root=await ctx.fs.resolve(workspaceRootOf(sessionId)),target=await resolveTarget(path,sessionId)
|
|
6291
|
+
if(!ctx.fs.contains(root,target))throw new Error('画布路径超出工作区')
|
|
6292
|
+
const scope={sessionId,path,drawioAbs:absoluteHint(target,path)}
|
|
6293
|
+
if(!['search','inspect'].includes(args.action)&&policyFor(sessionId)?.mode==='read-only')throw new Error('当前会话为只读')
|
|
6294
|
+
if(args.action==='search')return {summary:'音效搜索结果',details:JSON.stringify(await audioService.search(scope,args))}
|
|
6295
|
+
if(args.action==='inspect'||args.action==='configure')return documents.run(scope.drawioAbs,async()=>{
|
|
6296
|
+
const loaded=await loadDoc(path,sessionId,args.page),node=loaded.doc.nodes.find(n=>n.id===args.nodeId)
|
|
6297
|
+
if(!isAudioNode(node))throw new Error('请选择音频节点')
|
|
6298
|
+
if(args.action==='configure'){
|
|
6299
|
+
assertMotionEditable(loaded.doc,node.id)
|
|
6300
|
+
const before={data:JSON.parse(JSON.stringify(node.data)),style:node.style},patch=args.settings||{}
|
|
6301
|
+
const allowed=Object.keys(audioNodeData()) .filter(key=>key!=='drawaiAudio')
|
|
6302
|
+
if(Object.keys(patch).some(key=>!allowed.includes(key)))throw new Error('未知音频设置字段')
|
|
6303
|
+
node.data={...node.data,...Object.fromEntries(Object.entries(patch).map(([k,v])=>[k,String(v)]))}
|
|
6304
|
+
audioNodeSettings(node.data)
|
|
6305
|
+
await writeMotionDocument(loaded,sessionId,node.id,before)
|
|
6306
|
+
}
|
|
6307
|
+
const manifest=node.data.audioClip?frozenAudioFile(scope.drawioAbs,node.data.audioClip).manifest:null
|
|
6308
|
+
return {summary:'音频节点 '+node.id,details:JSON.stringify({nodeId:node.id,data:node.data,settings:audioNodeSettings(node.data),manifest})}
|
|
6309
|
+
})
|
|
6310
|
+
let asset
|
|
6311
|
+
if(args.action==='get')asset=await audioService.get(scope,args)
|
|
6312
|
+
else {
|
|
6313
|
+
if(!/\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i.test(args.file||''))throw new Error('请选择支持的音频文件')
|
|
6314
|
+
const source=await resolveTarget(args.file,sessionId)
|
|
6315
|
+
if(!ctx.fs.contains(root,source))throw new Error('音频路径超出工作区')
|
|
6316
|
+
const absolute=absoluteHint(source,args.file),relative=relativePath(realpathSync(absoluteHint(root,workspaceRootOf(sessionId))),realpathSync(absolute))
|
|
6317
|
+
if(isAbsolute(relative)||relative.startsWith('..'))throw new Error('音频真实路径超出工作区')
|
|
6318
|
+
asset=await importAudioFile(scope.drawioAbs,absolute,{role:args.role||'sfx'})
|
|
6319
|
+
}
|
|
6320
|
+
return documents.run(scope.drawioAbs,async()=>{
|
|
6321
|
+
const ops=[{op:'addNode',shape:'audio',audioRole:args.role||'sfx',audioClip:asset.clip,label:asset.manifest.name,x:args.x??80,y:args.y??80,as:'audio'}]
|
|
6322
|
+
if(args.targetId)ops.push({op:'addEdge',from:'audio',to:args.targetId})
|
|
6323
|
+
// 宿主要求无损 JSON 入参;未指定页码时必须省略,不能传 undefined。
|
|
6324
|
+
const result=await applyTool.execute({path,...(args.page!==undefined?{page:args.page}:{}),layout:'none',ops},exec)
|
|
6325
|
+
return {summary:'已添加音频节点',details:JSON.stringify({result,clip:asset.clip,manifest:asset.manifest})}
|
|
6326
|
+
})
|
|
6327
|
+
}
|
|
6328
|
+
})))
|
|
5740
6329
|
ctx.effect(() => ctx.tools.register(readTool))
|
|
6330
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6331
|
+
name: 'motion_task',
|
|
6332
|
+
description: '画布视频节点的任务记录:list 列出任务、status 查某个任务、cancel 取消、focus 在面板定位、remove 移除记录。开始制作请走 motion_project → 改 scene-*.tsx → motion_preview → motion_render;成片回填节点用 motion_render apply。',
|
|
6333
|
+
parameters: {
|
|
6334
|
+
action: { type: 'string', enum: ['list', 'status', 'cancel', 'focus', 'remove'], required: true },
|
|
6335
|
+
path: { type: 'string', description: '画布路径,省略时使用当前画布' },
|
|
6336
|
+
taskId: { type: 'string', description: '视频任务 ID' },
|
|
6337
|
+
nodeId: { type: 'string', description: '未指定 taskId 时,定位此视频节点的最新任务' },
|
|
6338
|
+
page: { type: 'number' },
|
|
6339
|
+
includeSource: { type: 'boolean', description: '需要检查或讨论场景代码时才开启' },
|
|
6340
|
+
},
|
|
6341
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
6342
|
+
summary: { type: 'string', required: true }, details: { type: 'string', required: true },
|
|
6343
|
+
} }, render: (args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6344
|
+
async execute(args, exec) {
|
|
6345
|
+
const sessionId = sessionIdOf(exec), path = args.path || focusedPathFor(sessionId)
|
|
6346
|
+
if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !isNativeCanvasPath(path)) throw new Error('请选择当前会话工作区中的 .drawai 画布')
|
|
6347
|
+
const target = await resolveTarget(path, sessionId)
|
|
6348
|
+
if (!ctx.fs.contains(await ctx.fs.resolve(workspaceRootOf(sessionId)), target)) throw new Error('画布路径超出工作区')
|
|
6349
|
+
const scope = { sessionId, path, drawioAbs: absoluteHint(target, path) }
|
|
6350
|
+
const compact = (job) => ({ ...job, composition: job.composition ? { ...job.composition,
|
|
6351
|
+
shots: job.composition.shots.map(({ sceneSource, sceneComponents, ...shot }) => ({ ...shot, ...(args.includeSource ? { sceneSource } : {}),
|
|
6352
|
+
...(sceneComponents ? { sceneComponents: sceneComponents.map(({ source, ...component }) => ({ ...component, ...(args.includeSource ? { source } : {}) })) } : {}) })) } : null })
|
|
6353
|
+
const candidates = motionTasks.list(scope).filter((job) => !args.nodeId || job.nodeId === args.nodeId)
|
|
6354
|
+
if (args.action === 'list') return { summary: '当前画布视频任务', details: JSON.stringify(candidates.map(({ composition: _composition, ...job }) => job)) }
|
|
6355
|
+
const ids = new Set(candidates.map((job) => job.nodeId))
|
|
6356
|
+
if (!args.taskId && ids.size > 1) throw new Error('有多个视频节点,请指定 nodeId 或 taskId')
|
|
6357
|
+
let job = args.taskId ? motionTasks.result(scope, args.taskId).job : candidates[0]
|
|
6358
|
+
if (!job || args.nodeId && job.nodeId !== args.nodeId) throw new Error('找不到此视频任务')
|
|
6359
|
+
if (['apply', 'cancel', 'remove'].includes(args.action) && policyFor(sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6360
|
+
if (args.action === 'remove') job = motionTasks.remove(scope, job.id)
|
|
6361
|
+
else if (args.action === 'cancel') job = await motionTasks.cancel(scope, job.id)
|
|
6362
|
+
else if (args.action === 'focus') {
|
|
6363
|
+
const original = motionTasks.details(scope, job.id), loaded = await loadTaskDocument({ scope: original.scope })
|
|
6364
|
+
if (!loaded.doc.nodes.some((node) => node.id === job.nodeId)) throw new Error('视频节点已删除')
|
|
6365
|
+
motionFocus.set(sessionId + '\n' + scope.drawioAbs, { id: randomUUID(), nodeId: job.nodeId, page: loaded.pageIndex })
|
|
6366
|
+
}
|
|
6367
|
+
const result = motionTasks.result(scope, job.id)
|
|
6368
|
+
return { summary: '视频任务:' + job.status, details: JSON.stringify({ ...compact(job),
|
|
6369
|
+
outputPath: result.output ? joinPath(clipDirFor(scope.drawioAbs, result.output.data.mediaClip), 'source.mp4') : undefined }) }
|
|
6370
|
+
},
|
|
6371
|
+
})))
|
|
6372
|
+
// ── 新工具面:三个动词,没有内层 agent ────────────────────────────────
|
|
6373
|
+
//
|
|
6374
|
+
// motion_project 建/读工程(只写文件,不启动 LLM)
|
|
6375
|
+
// motion_preview 渲几帧 → 直接回图(同回合能判断画面)
|
|
6376
|
+
// motion_render 起异步 job(绝不 await 到结束)
|
|
6377
|
+
//
|
|
6378
|
+
// 执行者是当前对话的主 agent:它有 read/grep/edit/pwsh,改的是普通 .tsx 小文件。
|
|
6379
|
+
const motionJobs = new RenderJobs()
|
|
6380
|
+
const motionProjectPath = (scope, nodeId) => {
|
|
6381
|
+
const canvas = process.platform === 'win32' ? scope.drawioAbs.toLowerCase() : scope.drawioAbs
|
|
6382
|
+
const key = createHash('sha256').update(JSON.stringify([canvas, scope.pageId, nodeId])).digest('hex')
|
|
6383
|
+
return joinPath(dirnamePath(scope.drawioAbs), '.drawai-motion', 'v2', key)
|
|
6384
|
+
}
|
|
6385
|
+
const motionScope = async (args, exec) => {
|
|
6386
|
+
const sessionId = sessionIdOf(exec)
|
|
6387
|
+
const path = args.path || focusedPathFor(sessionId)
|
|
6388
|
+
if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !isNativeCanvasPath(path)) throw new Error('请选择当前会话工作区中的 .drawai 画布')
|
|
6389
|
+
const target = await resolveTarget(path, sessionId)
|
|
6390
|
+
if (!ctx.fs.contains(await ctx.fs.resolve(workspaceRootOf(sessionId)), target)) throw new Error('画布路径超出工作区')
|
|
6391
|
+
const loaded = await loadDoc(path, sessionId, args.page)
|
|
6392
|
+
return { sessionId, path, drawioAbs: absoluteHint(target, path), pageId: loaded.pageId || 'page-0' }
|
|
6393
|
+
}
|
|
6394
|
+
const workspacePath = (scope, file, mustExist = true) => {
|
|
6395
|
+
const root = resolvePath(workspaceRootOf(scope.sessionId)), realRoot = realpathSync(root), target = resolvePath(file)
|
|
6396
|
+
const within = (base, value) => { const rel = relativePath(base, value); return !isAbsolute(rel) && rel !== '..' && !/^\.\.[\\/]/.test(rel) }
|
|
6397
|
+
if (!within(root, target)) throw new Error('路径超出工作区')
|
|
6398
|
+
let existing = target
|
|
6399
|
+
while (!existsSync(existing)) {
|
|
6400
|
+
const parent = dirnamePath(existing)
|
|
6401
|
+
if (parent === existing) throw new Error('无法验证路径')
|
|
6402
|
+
existing = parent
|
|
6403
|
+
}
|
|
6404
|
+
if (!within(realRoot, realpathSync(existing))) throw new Error('真实路径超出工作区')
|
|
6405
|
+
if (mustExist && !existsSync(target)) throw new Error('文件不存在:' + file)
|
|
6406
|
+
return target
|
|
6407
|
+
}
|
|
6408
|
+
const projectDirOf = (node, scope) => {
|
|
6409
|
+
const stored = node.data?.motion?.project
|
|
6410
|
+
const target = workspacePath(scope, motionProjectPath(scope, node.id), false)
|
|
6411
|
+
if (existsSync(target)) return target
|
|
6412
|
+
if (typeof stored !== 'string' || !stored) return target
|
|
6413
|
+
const legacy = workspacePath(scope, resolvePath(dirnamePath(scope.drawioAbs), stored), false)
|
|
6414
|
+
if (!existsSync(legacy) || legacy === target) return target
|
|
6415
|
+
// Existing projects are copied once; keep the old directory untouched, including its outputs.
|
|
6416
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') return legacy
|
|
6417
|
+
mkdirSync(dirnamePath(target), { recursive: true })
|
|
6418
|
+
const stage = workspacePath(scope, target + '.migrate-' + randomUUID(), false)
|
|
6419
|
+
try {
|
|
6420
|
+
cpSync(legacy, stage, { recursive: true, force: false, errorOnExist: true, filter: source => {
|
|
6421
|
+
if (lstatSync(source).isSymbolicLink()) throw new Error('旧工程包含链接,请先检查后手动迁移:' + source)
|
|
6422
|
+
return !relativePath(legacy, source).split(/[\\/]/).some(part => part === 'node_modules' || part === 'bundle')
|
|
6423
|
+
} })
|
|
6424
|
+
const jobFile = joinPath(stage, '.out', 'jobs.json')
|
|
6425
|
+
if (existsSync(jobFile)) {
|
|
6426
|
+
const records = parseProjectJson(readFileSync(jobFile, 'utf8'))
|
|
6427
|
+
if (Array.isArray(records)) {
|
|
6428
|
+
for (const job of records) {
|
|
6429
|
+
if (!job || typeof job !== 'object') continue
|
|
6430
|
+
job.dir = target
|
|
6431
|
+
if (typeof job.output === 'string' && isAbsolute(job.output)) {
|
|
6432
|
+
const rel = relativePath(legacy, job.output)
|
|
6433
|
+
job.output = !isAbsolute(rel) && rel !== '..' && !/^\.\.[\\/]/.test(rel) ? joinPath(target, rel) : null
|
|
6434
|
+
}
|
|
6435
|
+
}
|
|
6436
|
+
writeFileSync(jobFile, JSON.stringify(records, null, 2) + '\n', 'utf8')
|
|
6437
|
+
}
|
|
6438
|
+
}
|
|
6439
|
+
if (existsSync(target)) throw new Error('工程迁移期间目标已出现,请重试')
|
|
6440
|
+
renameSync(stage, target)
|
|
6441
|
+
} catch (error) { if (existsSync(stage)) rmSync(stage, { recursive: true, force: true }); throw error }
|
|
6442
|
+
return target
|
|
6443
|
+
}
|
|
6444
|
+
// 连到这个视频节点的镜头卡,顺序 = 画布上的连线顺序。
|
|
6445
|
+
const shotCardsOf = (doc, nodeId) => {
|
|
6446
|
+
const out = []
|
|
6447
|
+
for (const edge of motionReferenceEdges(doc, nodeId)) {
|
|
6448
|
+
if (motionReferenceRole(doc, edge) !== 'shotcard') continue
|
|
6449
|
+
const source = doc.nodes.find((item) => item.id === edge.from)
|
|
6450
|
+
if (!isShotCardNode(source)) continue
|
|
6451
|
+
const meta = shotCardCatalog.cards.find((item) => item.id === source.data.shotCardId)
|
|
6452
|
+
out.push({ nodeId: source.id, cardId: source.data.shotCardId, styleKey: source.data.shotStyleKey, duration: meta?.duration })
|
|
6453
|
+
}
|
|
6454
|
+
return out
|
|
6455
|
+
}
|
|
6456
|
+
// 连到这个视频节点的整片风格(一条片子一种;挂多个由 mgStyleSelection 报错)。
|
|
6457
|
+
const mgStyleOf = (doc, nodeId) => {
|
|
6458
|
+
for (const edge of motionReferenceEdges(doc, nodeId)) {
|
|
6459
|
+
if (motionReferenceRole(doc, edge) !== 'mgstyle') continue
|
|
6460
|
+
const source = doc.nodes.find((item) => item.id === edge.from)
|
|
6461
|
+
if (!isMgStyleNode(source)) continue
|
|
6462
|
+
return resolveMgStyle(mgStyleCatalog, source.data.mgStyleSlug)
|
|
6463
|
+
}
|
|
6464
|
+
return null
|
|
6465
|
+
}
|
|
6466
|
+
// 风格的节奏(fps / 时长 / 画幅)当规格缺省:选了一个风格,片子本来就该是这个节奏。
|
|
6467
|
+
const rhythmSpec = (rhythm) => {
|
|
6468
|
+
if (!rhythm) return {}
|
|
6469
|
+
const out = {}
|
|
6470
|
+
if (Number(rhythm.fps) >= 12 && Number(rhythm.fps) <= 30) out.fps = Number(rhythm.fps)
|
|
6471
|
+
if (Number(rhythm.duration) >= 2 && Number(rhythm.duration) <= 60) out.duration = Number(rhythm.duration)
|
|
6472
|
+
const aspect = String(rhythm.aspect || '')
|
|
6473
|
+
if (aspect === '9:16') { out.width = 1080; out.height = 1920 }
|
|
6474
|
+
else if (aspect === '4:5') { out.width = 1080; out.height = 1350 }
|
|
6475
|
+
else if (aspect === '1:1') { out.width = 1080; out.height = 1080 }
|
|
6476
|
+
else if (aspect === '16:9') { out.width = 1920; out.height = 1080 }
|
|
6477
|
+
return out
|
|
6478
|
+
}
|
|
6479
|
+
|
|
6480
|
+
/**
|
|
6481
|
+
* 动效素材包(字体 + 素材库,约 145MB)不在 npm 包里:第一次用到时下载一次,
|
|
6482
|
+
* 之后从 `~/.dsh/drawai-assets/` 直接读。下载失败就把「怎么自己解决」抛给用户,
|
|
6483
|
+
* 而不是让渲染在缺字体的地方以更难看的方式炸掉。
|
|
6484
|
+
*/
|
|
6485
|
+
async function ensureMotionAssets(reason) {
|
|
6486
|
+
if (assetsSummary().ready) return
|
|
6487
|
+
let lastPct = -1
|
|
6488
|
+
try {
|
|
6489
|
+
await requireAssets({
|
|
6490
|
+
onProgress: (got, total) => {
|
|
6491
|
+
const pct = total ? Math.floor((got / total) * 100) : -1
|
|
6492
|
+
if (pct !== lastPct) {
|
|
6493
|
+
lastPct = pct
|
|
6494
|
+
console.error('[drawai] 正在下载动效素材包 ' + (pct >= 0 ? pct + '%' : (got / 1048576).toFixed(1) + 'MB'))
|
|
6495
|
+
}
|
|
6496
|
+
},
|
|
6497
|
+
})
|
|
6498
|
+
console.error('[drawai] 动效素材包就绪')
|
|
6499
|
+
} catch (err) {
|
|
6500
|
+
throw new Error('(' + reason + ')' + (err?.message || err))
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
|
|
6504
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6505
|
+
name: 'motion_project',
|
|
6506
|
+
description: '视频工程:把画布里的一个视频节点落成磁盘上的小文件工程(timeline.json + scene-<n>.tsx + assets/ + theme.ts)。create 建工程并返回文件清单与分镜表,之后用 read/edit/write 直接改场景文件;open 重新读时间轴、文件清单和主题;theme 换配色(重写 theme.ts,场景里用它的常量就一起变)。这里不启动任何模型,也不渲染。按用户要求确定风格和素材;除非用户要求跳过,先制作 3–6 秒样片并回填到独立的样片视频节点,用户要求先审样时等待反馈。create 前镜头卡必须 addEdge 连到视频节点:连线同时是参考、镜头顺序和段长,画布上有断连的卡会直接报错。',
|
|
6507
|
+
parameters: {
|
|
6508
|
+
action: { type: 'string', enum: ['create', 'open', 'theme'], required: true },
|
|
6509
|
+
path: { type: 'string', description: '画布路径,省略时使用当前画布' },
|
|
6510
|
+
nodeId: { type: 'string', required: true, description: '视频节点 id' },
|
|
6511
|
+
page: { type: 'number' },
|
|
6512
|
+
spec: { type: 'json', description: '视频规格 {width,height,fps,duration},省略时沿用节点设置' },
|
|
6513
|
+
prompt: { type: 'string', description: '这条视频要什么,会写进 BRIEF.md' },
|
|
6514
|
+
segments: { type: 'number', description: '初始分几段,默认 4' },
|
|
6515
|
+
storyboard: { type: 'json', description: '分镜表:[{intent:"这一镜要做什么", onScreen:"画面上出现什么", copy:"配字"}],按镜头顺序;也可以给 ["一句话"] 简写。会写成 storyboard.md。' },
|
|
6516
|
+
theme: { type: 'string', description: '配色主题名:vintage-kraft / deep-ocean / obsidian-violet / modern-light / midnight / solar-pop / coral-burst / color-play / paper-ink。给了就在工程里生成 theme.ts,场景用 {{PAPER, INK, DUST, STAMP}} 引用。' },
|
|
6517
|
+
themeTokens: { type: 'json', description: '直接给一套色 {page,text,muted,accent,surface,field,border}(优先于 theme)。' },
|
|
6518
|
+
style: { type: 'string', description: '整片风格 slug(mg_style list 里的 slug,如 09-bauhaus)。给了就用这个风格:色板当主题、节奏当规格缺省、施工说明与引擎契约写进 STYLE.md。也可以不传,改为在画布上连一个「整片风格」节点。' },
|
|
6519
|
+
allowUnlinkedCards: { type: 'boolean', description: 'create 时允许画布上存在没连线的镜头卡(当作灵感,不进 assets/timeline)。默认不允许:断连的卡会同时漏掉参考与排期,所以直接报错,让你补 addEdge。' },
|
|
6520
|
+
},
|
|
6521
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6522
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6523
|
+
async execute(args, exec) {
|
|
6524
|
+
const scope = await motionScope(args, exec)
|
|
6525
|
+
if (args.action === 'create' && policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6526
|
+
return documents.run(scope.drawioAbs, async () => {
|
|
6527
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6528
|
+
const node = loaded.doc.nodes.find((item) => item.id === args.nodeId)
|
|
6529
|
+
if (!node) throw new Error('找不到视频节点 ' + args.nodeId)
|
|
6530
|
+
const dir = projectDirOf(node, scope)
|
|
6531
|
+
if (args.action === 'theme') {
|
|
6532
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6533
|
+
if (!existsSync(dir)) throw new Error('工程还没建:先 action:"create"')
|
|
6534
|
+
const resolved = resolveTheme(args.themeTokens || args.theme)
|
|
6535
|
+
if (!resolved) throw new Error('请给 theme(主题名)或 themeTokens(一套色)。可选主题:' + listThemes().map((item) => item.name).join('、'))
|
|
6536
|
+
const written = writeTheme(dir, resolved)
|
|
6537
|
+
return { summary: '已换主题:' + written.name, details: JSON.stringify({ dir, theme: written, themes: listThemes() }) }
|
|
6538
|
+
}
|
|
6539
|
+
if (args.action === 'open') {
|
|
6540
|
+
const timeline = readTimeline(dir)
|
|
6541
|
+
return { summary: '视频工程 ' + dir, details: JSON.stringify({ dir, timeline, files: listFiles(dir),
|
|
6542
|
+
theme: readTheme(dir), output: outputInfo(outputPath(dir, 'film.mp4')) }) }
|
|
6543
|
+
}
|
|
6544
|
+
if (!isMotionNode(node)) throw new Error('请选择空视频或绘制视频节点')
|
|
6545
|
+
const before = { data: JSON.parse(JSON.stringify(node.data)), style: node.style }
|
|
6546
|
+
const style = args.style ? resolveMgStyle(mgStyleCatalog, args.style) : mgStyleOf(loaded.doc, node.id)
|
|
6547
|
+
const kit = style ? mgStyleKit({ slug: style.slug }) : null
|
|
6548
|
+
// 规格优先级:风格节奏(最弱)< 节点上存的 < 这次显式给的
|
|
6549
|
+
const spec = readSpec({ ...rhythmSpec(style?.rhythm), ...(node.data.motion || {}), ...(args.spec || {}) })
|
|
6550
|
+
const errors = validateSpec(spec)
|
|
6551
|
+
if (errors.length) throw new Error('视频规格不合法:' + errors.join(';'))
|
|
6552
|
+
const cards = shotCardsOf(loaded.doc, node.id)
|
|
6553
|
+
// 镜头卡不连线 = 参考、顺序、段长三样一起丢。这里不靠"提示",直接拦。
|
|
6554
|
+
const unlinked = unlinkedShotCards(loaded.doc)
|
|
6555
|
+
if (unlinked.length > 0 && !args.allowUnlinkedCards) {
|
|
6556
|
+
throw new Error('有 ' + unlinked.length + ' 张镜头卡没连到视频节点(' + unlinked.map((item) => item.nodeId).join(', ') + '):'
|
|
6557
|
+
+ '连线同时决定「进 assets 当参考」「镜头顺序」「每段时长」,断连的卡三样都不算。'
|
|
6558
|
+
+ '先读回节点坐标,自行规划正交折点,再 diagram_apply addEdge{from:"卡 id", to:"' + node.id + '", line:"rounded", points:[折点坐标]} 把它们连上;'
|
|
6559
|
+
+ '如果这些卡只是灵感、不参与排期,删掉它们,或传 allowUnlinkedCards:true 并在 BRIEF.md 里注明。')
|
|
6560
|
+
}
|
|
6561
|
+
// 分镜表的真相源在画布上:没显式给 storyboard 时读【分镜表】节点(一张表、每镜一行)。
|
|
6562
|
+
// 这也是"分镜被拆成八条文本节点"能被发现的地方 —— readCanvasSpec 会把它们列出来。
|
|
6563
|
+
const canvasSpec = readCanvasSpec(loaded.doc, { fps: spec.fps })
|
|
6564
|
+
const storyboardInput = args.storyboard !== undefined
|
|
6565
|
+
? args.storyboard
|
|
6566
|
+
: canvasSpec.storyboard.length > 0 ? canvasSpec.storyboard : undefined
|
|
6567
|
+
// 显式给了时长就以时长为总长:卡片之间按相对时长等比拉伸,不再被卡片总和改短。
|
|
6568
|
+
const durationGiven = Number(args.spec?.duration) > 0
|
|
6569
|
+
// 建工程要往 assets/fonts 里拷字体:先确保素材包在(首次会下载一次)。
|
|
6570
|
+
await ensureMotionAssets('建视频工程')
|
|
6571
|
+
const created = createProject(dir, { spec, prompt: args.prompt ?? node.data.motion?.prompt, segments: args.segments, cards, storyboard: storyboardInput,
|
|
6572
|
+
theme: args.themeTokens || args.theme, style: kit, fitFrames: durationGiven ? spec.totalFrames : 0 })
|
|
6573
|
+
registerProject(dir)
|
|
6574
|
+
const seconds = created.timeline.durationInFrames / created.timeline.fps
|
|
6575
|
+
node.data.motion = { ...(node.data.motion || {}), mode: spec.mode, width: created.timeline.width, height: created.timeline.height, fps: created.timeline.fps, duration: Math.round(seconds * 100) / 100,
|
|
6576
|
+
prompt: String(args.prompt ?? node.data.motion?.prompt ?? '').slice(0, 12000),
|
|
6577
|
+
project: relativePath(dirnamePath(scope.drawioAbs), dir) }
|
|
6578
|
+
await writeMotionDocument(loaded, scope.sessionId, node.id, before)
|
|
6579
|
+
const warnings = [...(created.warnings || [])]
|
|
6580
|
+
if (cards.length === 0) warnings.push(created.storyboard.length >= 2
|
|
6581
|
+
? '没有连镜头卡:段数按分镜表的 ' + created.storyboard.length + ' 行开,时长取表里的帧区间(没写就均分);画面参考不来自画布。'
|
|
6582
|
+
: '没有连镜头卡:时间轴按 segments 均分,镜头顺序与段长都不来自画布。要按卡排布就先 addEdge(见 next)。')
|
|
6583
|
+
if (unlinked.length > 0) warnings.push('有 ' + unlinked.length + ' 张镜头卡未连线(' + unlinked.map((item) => item.nodeId).join(', ') + '):按灵感处理,不进 assets、不进 timeline。')
|
|
6584
|
+
return { summary: '已建视频工程,' + created.timeline.shots.length + ' 段 / ' + seconds.toFixed(2) + ' 秒'
|
|
6585
|
+
+ (cards.length
|
|
6586
|
+
? '(' + (durationGiven ? '按 ' + cards.length + ' 张镜头卡等比拉到指定时长' : '按 ' + cards.length + ' 张镜头卡排布') + ')'
|
|
6587
|
+
: '(均分,未挂镜头卡 ⚠)')
|
|
6588
|
+
+ (unlinked.length ? ',' + unlinked.length + ' 张卡未连线' : '')
|
|
6589
|
+
+ (created.storyboard.length ? ',分镜表 ' + created.storyboard.length + ' 行' : ',还没有分镜表')
|
|
6590
|
+
+ (created.theme ? ',主题 ' + created.theme : '')
|
|
6591
|
+
+ (style ? ',风格 ' + style.name + (style.engines?.length ? '(引擎 ' + style.engines.map((engine) => ENGINE_LABEL[engine] || engine).join(' + ') + ')' : '') + ',施工说明见 STYLE.md' : ''), details: JSON.stringify({
|
|
6592
|
+
dir, files: created.files, timeline: created.timeline, cards, storyboard: created.storyboard,
|
|
6593
|
+
...(canvasSpec.nodes.storyboard ? { canvasSpec: { storyboardNode: canvasSpec.nodes.storyboard, rows: canvasSpec.storyboard.length } } : {}),
|
|
6594
|
+
...(canvasSpec.problems.length ? { canvasSpecProblems: canvasSpec.problems } : {}),
|
|
6595
|
+
...(style ? { style: { slug: style.slug, name: style.name, commit: style.commit, signature: style.signature, engine: style.engine, engines: style.engines, engineNote: style.engineNote } } : {}),
|
|
6596
|
+
...(warnings.length ? { warnings } : {}),
|
|
6597
|
+
next: [
|
|
6598
|
+
...(warnings.length ? warnings : []),
|
|
6599
|
+
...(style ? ['动手前先读工程里的 STYLE.md:判据、色板、节奏、引擎契约、要避的坑都在里面,凭风格名自由发挥就不像了。'] : []),
|
|
6600
|
+
...(style?.engines?.some((engine) => GATED_ENGINES.has(engine))
|
|
6601
|
+
? ['这个风格的上游是 ' + style.engines.map((engine) => ENGINE_LABEL[engine] || engine).join(' + ') + ':先把引擎骨架跑通(canvas + 真渲染器 + 后处理),再往里排内容;用 CSS 3D 或 Canvas2D 简化会在 motion_render 被拒绝。']
|
|
6602
|
+
: []),
|
|
6603
|
+
created.storyboard.length
|
|
6604
|
+
? '分镜表已写进 storyboard.md。'
|
|
6605
|
+
: '还没分镜表:先 motion_spec{action:"scaffold"} 在画布空位建一张【分镜表】节点,把每一镜写成表里的一行,再 create 一次(分镜表只能有一张,不要一镜一个节点)。',
|
|
6606
|
+
...(canvasSpec.problems.length ? ['画布规格要先修:' + canvasSpec.problems.join(';') + '(motion_spec{action:"validate"} 会逐条报)'] : []),
|
|
6607
|
+
'开工前自检两件事:风格与色调是问过用户(或他明说你定)的吗?品牌 logo/字标是用户给的、或按官方规范取的原图吗?——没确认就回去补,这两件错了整片要重做。',
|
|
6608
|
+
'先出定调帧:只写 scene-0 → motion_preview{frames:[首段中点]} 看一张静帧,确认纸色/字重/留白/动效气质。',
|
|
6609
|
+
'做 3–6 秒样片:主节点 motion_render start + range,记下 pilot jobId → status 确认 done → diagram_apply 建或复用独立「样片」视频节点 → motion_render apply {nodeId:"样片节点 id",jobId:"pilot jobId",source:"pilot"},确认回填并告知用户。用户明确跳过时除外;用户要求先审样时再等待反馈。',
|
|
6610
|
+
cards.length
|
|
6611
|
+
? '顺序已按镜头卡时长排好:用 read 打开 scene-0.tsx 开始写第一段。'
|
|
6612
|
+
: '这个节点没有连镜头卡,时间轴按段数均分。要按卡排布:先 shotcraft_search 选卡 → diagram_apply addNode{shape:"shotcard"} + addEdge 连到本节点 → 删掉 timeline.json 后重新 create。',
|
|
6613
|
+
].join(' ') }) }
|
|
6614
|
+
})
|
|
6615
|
+
},
|
|
6616
|
+
})))
|
|
6617
|
+
|
|
6618
|
+
// 画布规格三块:【分镜表】/【风格】/【旁白】。
|
|
6619
|
+
//
|
|
6620
|
+
// 以前这三块只活在对话里(或 storyboard.md 里),于是"分镜表"在画布上就变成
|
|
6621
|
+
// **一镜一个文本节点**——八镜八条,谁也不觉得那是一张表,motion_project 也读不到。
|
|
6622
|
+
// 这里给一个工具:建(scaffold)、读(read)、验(validate)。
|
|
6623
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6624
|
+
name: 'motion_spec',
|
|
6625
|
+
description: '画布上的视频规格三块,用**一个节点**写清楚:\n【分镜表】一张 Markdown 六列表(镜头 | 帧区间 | 镜头卡 | 这一镜要做什么 | 屏幕上出现什么 | 文案 / 声音),每一镜一行——**不要一镜建一个节点**;\n【风格】一行一个键(page #F7F3EA / serif "Noto Serif SC" / materials: kraft);\n【旁白】`## shot-N` 分节,一句一行。\naction:"scaffold" 返回把缺的块摆到视频附近空位的 diagram_apply ops(已有一张【分镜表】就不再建第二张);"read" 读回来;"validate" 逐条报错(分镜被拆成多条、帧数之和对不上节点时长、镜号不连号…)。motion_project create 不给 storyboard 时会直接读这张表。',
|
|
6626
|
+
parameters: {
|
|
6627
|
+
action: { type: 'string', enum: ['scaffold', 'read', 'validate'], required: true },
|
|
6628
|
+
path: { type: 'string', description: '画布路径,省略时使用当前画布' },
|
|
6629
|
+
nodeId: { type: 'string', description: '视频节点 id(规格块的锚点)' },
|
|
6630
|
+
page: { type: 'number' },
|
|
6631
|
+
storyboard: { type: 'json', description: '分镜行 [{intent,onScreen,copy,card,durationFrames}],或一段 markdown 表;省略时 scaffold 只出表头' },
|
|
6632
|
+
style: { type: 'json', description: '风格:一段文本,或 {palette,fonts,materials}' },
|
|
6633
|
+
narration: { type: 'string', description: '旁白脚本(## shot-N 分节,一句一行)' },
|
|
6634
|
+
spec: { type: 'json', description: '视频规格 {width,height,fps,duration},省略时读节点上的' },
|
|
6635
|
+
},
|
|
6636
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6637
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6638
|
+
async execute(args, exec) {
|
|
6639
|
+
const scope = await motionScope(args, exec)
|
|
6640
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6641
|
+
const node = args.nodeId ? loaded.doc.nodes.find((item) => item.id === args.nodeId) : loaded.doc.nodes.find(isMotionNode)
|
|
6642
|
+
if (!node) throw new Error('找不到视频节点' + (args.nodeId ? ' ' + args.nodeId : ''))
|
|
6643
|
+
const spec = readSpec({ ...(node.data?.motion || {}), ...(args.spec || {}) })
|
|
6644
|
+
if (args.action === 'scaffold') {
|
|
6645
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6646
|
+
const built = scaffoldOps(loaded.doc, { videoNode: node, storyboard: args.storyboard, style: args.style,
|
|
6647
|
+
narration: args.narration, fps: spec.fps, totalFrames: spec.totalFrames })
|
|
6648
|
+
if (built.ops.length === 0) {
|
|
6649
|
+
return { summary: '规格块已经齐了(' + built.already.join('、') + '),不必再建',
|
|
6650
|
+
details: JSON.stringify({ already: built.already, next: '直接 read / validate;要改就用 diagram_apply 的 setLabel' }) }
|
|
6651
|
+
}
|
|
6652
|
+
return { summary: '已生成 ' + built.ops.length + ' 个规格节点的 ops(' + built.created.join('、') + ')',
|
|
6653
|
+
details: JSON.stringify({ ops: built.ops, created: built.created,
|
|
6654
|
+
next: '把 ops 原样传给 diagram_apply(layout:"none"),然后 motion_spec{action:"validate"} 验一遍;分镜表只有这一张,每镜写成表里的一行。' }) }
|
|
6655
|
+
}
|
|
6656
|
+
if (args.action === 'validate') {
|
|
6657
|
+
const result = validateCanvasSpec(loaded.doc, { spec })
|
|
6658
|
+
const unlinked = unlinkedShotCards(loaded.doc)
|
|
6659
|
+
if (unlinked.length > 0) {
|
|
6660
|
+
result.errors.push('有 ' + unlinked.length + ' 张镜头卡没连到视频节点(' + unlinked.map((item) => item.nodeId).join(', ')
|
|
6661
|
+
+ '):连线同时决定参考、镜头顺序和段长,断连等于三样都丢。addEdge 连上,或删掉这些卡')
|
|
6662
|
+
}
|
|
6663
|
+
return { summary: result.errors.length === 0
|
|
6664
|
+
? '规格没问题' + (result.warnings.length ? '(' + result.warnings.length + ' 条提醒)' : '')
|
|
6665
|
+
: '规格有 ' + result.errors.length + ' 个问题要先修',
|
|
6666
|
+
details: JSON.stringify({ errors: result.errors, warnings: result.warnings,
|
|
6667
|
+
nodes: result.read.nodes, rows: result.read.storyboard.length,
|
|
6668
|
+
next: result.errors.length ? '先 motion_spec{action:"scaffold"} 补一张【分镜表】/修掉重复节点,再用 diagram_apply setLabel 改表内容' : '' }) }
|
|
6669
|
+
}
|
|
6670
|
+
const read = readCanvasSpec(loaded.doc, { fps: spec.fps })
|
|
6671
|
+
return { summary: '画布规格:分镜 ' + read.storyboard.length + ' 行'
|
|
6672
|
+
+ (read.nodes.style ? ' · 风格 ' + Object.keys(read.style.tokens).length + ' 个色' : '')
|
|
6673
|
+
+ (read.nodes.narration ? ' · 旁白 ' + read.narration.sections.length + ' 节' : '')
|
|
6674
|
+
+ (read.problems.length ? ' · ' + read.problems.length + ' 个问题' : ''),
|
|
6675
|
+
details: JSON.stringify({ storyboard: read.storyboard, style: read.style, narration: read.narration,
|
|
6676
|
+
nodes: read.nodes, problems: read.problems }) }
|
|
6677
|
+
},
|
|
6678
|
+
})))
|
|
6679
|
+
|
|
6680
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6681
|
+
name: 'motion_preview',
|
|
6682
|
+
description: '渲染工程里的几帧并**直接返回拼图**(同一轮就能看见画面)。第一次调用要打包(约 10–30 秒),之后同一份源码复用缓存只要几秒。frames 是绝对帧号,例如 [0,60,120,180];省略时取每段的中点(最多 6 帧),定调阶段只写 scene-0 时用它看一张定妆帧。**要看画质(锯齿、色带、字重)就传 crop:只出 1:1 原尺寸局部**,拼图是缩过的,缩过的图看不出这些毛病。',
|
|
6683
|
+
parameters: {
|
|
6684
|
+
path: { type: 'string' }, page: { type: 'number' }, nodeId: { type: 'string', required: true },
|
|
6685
|
+
frames: { type: 'json', description: '绝对帧号数组,最多 9 帧;省略 = 每段中点' },
|
|
6686
|
+
cols: { type: 'number', description: '拼图列数,默认 3' },
|
|
6687
|
+
crop: { type: 'json', description: '只出局部:{x,y,w,h} 或 {cx,cy,w,h},像素。传了就按 1:1 原尺寸返回(不缩放),用来判断 AA / 色带 / 字体' },
|
|
6688
|
+
},
|
|
6689
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
6690
|
+
summary: { type: 'string', required: true },
|
|
6691
|
+
details: { type: 'string', required: true },
|
|
6692
|
+
image: { type: 'object', additionalProperties: false, properties: {
|
|
6693
|
+
attachmentId: { type: 'string', required: true }, mediaType: { type: 'string' }, bytes: { type: 'number' },
|
|
6694
|
+
width: { type: 'number' }, height: { type: 'number' }, name: { type: 'string' } } } } },
|
|
6695
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }, ...(value.image ? [{ type: 'image', attachment: value.image }] : [])] },
|
|
6696
|
+
async execute(args, exec) {
|
|
6697
|
+
const scope = await motionScope(args, exec)
|
|
6698
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6699
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6700
|
+
const node = loaded.doc.nodes.find((item) => item.id === args.nodeId)
|
|
6701
|
+
if (!node) throw new Error('找不到视频节点 ' + args.nodeId)
|
|
6702
|
+
const dir = projectDirOf(node, scope)
|
|
6703
|
+
const requested = Array.isArray(args.frames) ? args.frames.map(Number).filter((n) => Number.isFinite(n)).slice(0, 9) : []
|
|
6704
|
+
const frames = requested.length ? requested : (() => {
|
|
6705
|
+
const timeline = readTimeline(dir)
|
|
6706
|
+
return timeline.shots.slice(0, 6).map((shot) => shot.startFrame + Math.floor(shot.durationFrames * 0.6))
|
|
6707
|
+
})()
|
|
6708
|
+
const preview = await previewProject(dir, { frames, cols: args.cols, crop: args.crop, attachments: ctx.get?.('attachments') })
|
|
6709
|
+
const payload = { summary: '已渲染 ' + frames.length + ' 帧并拼图' + (preview.crop ? '(1:1 局部 ' + preview.crop.w + '×' + preview.crop.h + ')' : ''), details: JSON.stringify({ frames: preview.frames, sheet: preview.sheet, crop: preview.crop }) }
|
|
6710
|
+
const ref = preview.attachment
|
|
6711
|
+
if (ref && typeof ref.attachmentId === 'string') {
|
|
6712
|
+
const image = { attachmentId: String(ref.attachmentId) }
|
|
6713
|
+
if (typeof ref.mediaType === 'string') image.mediaType = ref.mediaType
|
|
6714
|
+
if (Number.isFinite(Number(ref.bytes))) image.bytes = Number(ref.bytes)
|
|
6715
|
+
if (Number.isFinite(Number(ref.width))) image.width = Number(ref.width)
|
|
6716
|
+
if (Number.isFinite(Number(ref.height))) image.height = Number(ref.height)
|
|
6717
|
+
if (typeof ref.name === 'string') image.name = ref.name
|
|
6718
|
+
payload.image = image
|
|
6719
|
+
}
|
|
6720
|
+
return payload
|
|
6721
|
+
},
|
|
6722
|
+
})))
|
|
6723
|
+
|
|
6724
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6725
|
+
name: 'motion_render',
|
|
6726
|
+
description: '渲染工程。start 立即返回 jobId(后台跑,不要等);**start 前会按 STYLE.md 的引擎契约扫工程源码,three / webgl2 风格缺真引擎会直接拒绝渲染**(样片也拦;确实要换实现先在 STYLE.md 写「偏离:引擎 …」);range 渲的是**样片**(写 .out/pilot.mp4,任务 kind=pilot),不传就是整片(.out/film.mp4);status 查进度;list 列最近任务;cancel 取消;apply 把渲好的片子回填到视频节点——**nodeId 决定回填到哪个节点**(样片回填到另建的「样片」节点,成片回填到主视频节点),不给 jobId 时主视频节点取整片、样片节点取样片。frames 只渲染指定帧。',
|
|
6727
|
+
parameters: {
|
|
6728
|
+
action: { type: 'string', enum: ['start', 'status', 'list', 'cancel', 'apply'], required: true },
|
|
6729
|
+
path: { type: 'string' }, page: { type: 'number' }, nodeId: { type: 'string', description: 'start:从哪个视频节点对应的工程渲;apply:回填到哪个节点(省略时:主视频节点回填整片、另建的样片节点回填样片)' }, jobId: { type: 'string' },
|
|
6730
|
+
source: { type: 'string', enum: ['film', 'pilot'], description: 'apply 时回填哪一种:film=整片(主视频节点)、pilot=样片(样片节点)' },
|
|
6731
|
+
frames: { type: 'json', description: '只渲染这些帧' },
|
|
6732
|
+
range: { type: 'json', description: '帧区间 [from,to]:渲样片(写 pilot.mp4)' },
|
|
6733
|
+
},
|
|
6734
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6735
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6736
|
+
async execute(args, exec) {
|
|
6737
|
+
const scope = await motionScope(args, exec)
|
|
6738
|
+
if (args.action === 'list') {
|
|
6739
|
+
const listed = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6740
|
+
const listedNode = args.nodeId ? listed.doc.nodes.find(item => item.id === args.nodeId) : listed.doc.nodes.find(isMotionNode)
|
|
6741
|
+
const jobs = listedNode ? motionJobs.list(projectDirOf(listedNode, scope)) : []
|
|
6742
|
+
return { summary: '最近渲染任务', details: JSON.stringify(jobs.slice(0, 10)) }
|
|
6743
|
+
}
|
|
6744
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6745
|
+
const node = args.nodeId ? loaded.doc.nodes.find((item) => item.id === args.nodeId) : loaded.doc.nodes.find(isMotionNode)
|
|
6746
|
+
if (!node) throw new Error('找不到视频节点')
|
|
6747
|
+
let dir = projectDirOf(node, scope)
|
|
6748
|
+
if (args.action === 'status') {
|
|
6749
|
+
const job = motionJobs.get(args.jobId)
|
|
6750
|
+
if (!job || job.dir !== dir) throw new Error('找不到当前工程的任务 ' + args.jobId)
|
|
6751
|
+
const mix = job.mix ? describeMix(job.mix) : (job.mixError ? '响度归一失败:' + job.mixError : '')
|
|
6752
|
+
const next = job.kind === 'pilot' && job.status === 'done'
|
|
6753
|
+
? '样片文件不会自动显示在画布:在当前画布页创建或复用独立「样片」视频节点,记下其 ID,然后调用 motion_render {action:"apply",nodeId:"样片节点 ID",jobId:"' + job.id + '",source:"pilot"};核对结果并告知用户。'
|
|
6754
|
+
: undefined
|
|
6755
|
+
return { summary: '渲染任务:' + job.status + (mix ? ' · ' + mix : ''), details: JSON.stringify({ ...job, ...(next ? { next } : {}) }) }
|
|
6756
|
+
}
|
|
6757
|
+
if (args.action === 'cancel') {
|
|
6758
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6759
|
+
const job = motionJobs.get(args.jobId)
|
|
6760
|
+
if (!job || job.dir !== dir) throw new Error('找不到当前工程的任务 ' + args.jobId)
|
|
6761
|
+
motionJobs.cancel(args.jobId); return { summary: '已请求取消', details: JSON.stringify({ jobId: args.jobId }) }
|
|
6762
|
+
}
|
|
6763
|
+
if (args.action === 'apply') {
|
|
6764
|
+
// 目标节点可能是个没建过工程的「样片」节点:工程目录得从**主视频节点**取。
|
|
6765
|
+
const targetId = args.nodeId || (args.jobId ? (motionJobs.get(args.jobId)?.nodeId || node.id) : node.id)
|
|
6766
|
+
const targetNode = loaded.doc.nodes.find((item) => item.id === targetId)
|
|
6767
|
+
const hasProject = (item) => typeof item?.data?.motion?.project === 'string' && !!item.data.motion.project
|
|
6768
|
+
const candidates = loaded.doc.nodes.filter(item => isMotionNode(item) && hasProject(item))
|
|
6769
|
+
let projectNode = hasProject(targetNode) ? targetNode : null
|
|
6770
|
+
if (!projectNode && args.jobId) {
|
|
6771
|
+
const matching = candidates.filter(item => {
|
|
6772
|
+
const candidateDir = projectDirOf(item, scope)
|
|
6773
|
+
return motionJobs.list(candidateDir).some(item => item.id === args.jobId && item.dir === candidateDir)
|
|
6774
|
+
})
|
|
6775
|
+
if (matching.length > 1) throw new Error('任务属于多个工程,请指定正确的源工程')
|
|
6776
|
+
projectNode = matching[0] || null
|
|
6777
|
+
}
|
|
6778
|
+
if (!projectNode && candidates.length === 1) projectNode = candidates[0]
|
|
6779
|
+
if (!projectNode && candidates.length > 1) throw new Error('画布有多个视频工程,回填样片时请提供对应的 jobId')
|
|
6780
|
+
projectNode ||= node
|
|
6781
|
+
dir = projectDirOf(projectNode, scope)
|
|
6782
|
+
const jobs = motionJobs.list(dir).filter((item) => item.kind === 'film' || item.kind === 'pilot')
|
|
6783
|
+
const finished = jobs.filter((item) => item.status === 'done' || item.output)
|
|
6784
|
+
let job = args.jobId ? (motionJobs.get(args.jobId) || jobs.find((item) => item.id === args.jobId)) : null
|
|
6785
|
+
if (args.jobId && (!job || job.dir !== dir)) throw new Error('找不到当前工程的任务 ' + args.jobId)
|
|
6786
|
+
if (job && job.kind === 'still') throw new Error('任务 ' + job.id + ' 是静帧预览(still),不能当片子回填')
|
|
6787
|
+
// 回填到哪个节点:**调用方说了算**(以前只要 job 带 nodeId 就忽略 args.nodeId,
|
|
6788
|
+
// 于是"把样片 apply 到另建的样片节点"永远写回主节点 —— 实测出过这个事故)。
|
|
6789
|
+
if (!job) {
|
|
6790
|
+
// 主视频节点(motion_project 建过工程)等的是成片;另建的「样片」节点等的是样片。
|
|
6791
|
+
// 以前两者都取"最新 done film",而样片 range 渲染写的也是 film.mp4 —— 4 秒样片会被当成整片写进主节点。
|
|
6792
|
+
const sampleTarget = targetNode !== undefined && !hasProject(targetNode)
|
|
6793
|
+
const preferred = applyJobKind({ source: args.source, targetHasProject: !sampleTarget })
|
|
6794
|
+
job = finished.find((item) => item.kind === preferred)
|
|
6795
|
+
if (!job && preferred === 'pilot') {
|
|
6796
|
+
throw new Error('这个节点还没有样片:先 motion_render{action:"start", range:[起,止]}(样片渲成 .out/pilot.mp4,任务 kind=pilot)')
|
|
6797
|
+
}
|
|
6798
|
+
if (!job) job = finished.find((item) => item.kind === 'film') || finished.find((item) => item.kind === 'pilot')
|
|
6799
|
+
}
|
|
6800
|
+
if (!job) throw new Error('没有可回填的成片:先 motion_render start 出一版')
|
|
6801
|
+
const film = workspacePath(scope, job.output || outputPath(dir, job.kind === 'pilot' ? 'pilot.mp4' : 'film.mp4'))
|
|
6802
|
+
if (!existsSync(film)) throw new Error('成片文件不在了:' + film + '(重新 motion_render start)')
|
|
6803
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6804
|
+
return documents.run(scope.drawioAbs, async () => {
|
|
6805
|
+
const live = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6806
|
+
const target = live.doc.nodes.find((item) => item.id === targetId)
|
|
6807
|
+
if (!target) throw new Error('要回填的节点已被删除(' + targetId + ')')
|
|
6808
|
+
const before = { data: JSON.parse(JSON.stringify(target.data)), style: target.style }
|
|
6809
|
+
const result = await applyFilm({ drawioAbs: scope.drawioAbs, doc: live.doc, node: target, film })
|
|
6810
|
+
target.data = { ...target.data, ...result.attrs }
|
|
6811
|
+
// 海报是 PNG data URI:按型别分派(以前一律当 SVG 包一层,节点上就是空图)。
|
|
6812
|
+
if (result.poster) target.style = styleWithMediaMarkup(target.style || '', result.poster, '0')
|
|
6813
|
+
if (!target.label) target.label = job.kind === 'pilot' ? '样片' : '成片'
|
|
6814
|
+
await writeMotionDocument(live, scope.sessionId, target.id, before)
|
|
6815
|
+
const seconds = result.seconds > 0 ? result.seconds.toFixed(2) : '?'
|
|
6816
|
+
return { summary: (job.kind === 'pilot' ? '样片' : '成片') + '已回填到节点 ' + target.id + '(' + result.width + '×' + result.height + ' · ' + seconds + 's · ' + (result.bytes / 1024 / 1024).toFixed(2) + 'MB)',
|
|
6817
|
+
details: JSON.stringify({ jobId: job.id, kind: job.kind, nodeId: target.id, clip: result.clip, dir: result.dir, source: result.source,
|
|
6818
|
+
width: result.width, height: result.height, fps: result.fps, seconds: result.seconds, frames: result.frames, bytes: result.bytes,
|
|
6819
|
+
next: '画布上点播放就能看;要再改就去改 scene-*.tsx 再渲染一次,apply 会覆盖同一个 clip' }) }
|
|
6820
|
+
})
|
|
6821
|
+
}
|
|
6822
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6823
|
+
const operation = Array.isArray(args.frames) && args.frames.length ? 'still' : 'full'
|
|
6824
|
+
const ranged = Array.isArray(args.range) && args.range.length === 2
|
|
6825
|
+
// 样片的输出必须跟整片分开:以前 range 渲染也写 film.mp4,下一次 apply 就会把 4 秒样片当整片塞进主节点。
|
|
6826
|
+
const kind = operation === 'still' ? 'still' : ranged ? 'pilot' : 'film'
|
|
6827
|
+
const outputName = kind === 'still' ? 'still.mp4' : kind === 'pilot' ? 'pilot.mp4' : 'film.mp4'
|
|
6828
|
+
// 缺素材时渲染会在「字体没注册」那里炸,说不清原因;先确保素材包,报错才说得明白。
|
|
6829
|
+
await ensureMotionAssets('渲染视频')
|
|
6830
|
+
// 引擎保真:样片和成片都要用风格上游那一套引擎,不许「简化实现」骗过静帧。
|
|
6831
|
+
const styleForGate = (() => {
|
|
6832
|
+
const saved = readTimeline(dir).style
|
|
6833
|
+
if (!saved) return null
|
|
6834
|
+
const entry = mgStyleCatalog.styles.find((item) => item.slug === saved.slug)
|
|
6835
|
+
return { ...saved,
|
|
6836
|
+
engine: saved.engine || entry?.engine || null,
|
|
6837
|
+
engines: saved.engines?.length ? saved.engines : (entry?.engines || []),
|
|
6838
|
+
engineNote: saved.engineNote || entry?.engineNote || '',
|
|
6839
|
+
engineFiles: saved.engineFiles?.length ? saved.engineFiles : (entry?.engineFiles || []) }
|
|
6840
|
+
})()
|
|
6841
|
+
const engineGate = kind === 'still' ? null : assertEngineFidelity(dir, styleForGate)
|
|
6842
|
+
// 质量证据门:字体没注册直接拒渲;后处理/累积缺席带 warning 一起报出来。
|
|
6843
|
+
const qualityGate = kind === 'still' ? null : assertQualityEvidence(dir, styleForGate)
|
|
6844
|
+
const gateWarnings = [engineGate?.warning, ...(qualityGate?.warnings || [])].filter(Boolean)
|
|
6845
|
+
const job = motionJobs.create({ kind, dir, nodeId: node.id })
|
|
6846
|
+
const handle = renderProject(dir, { operation, frames: args.frames, frameRange: ranged ? args.range : undefined,
|
|
6847
|
+
output: outputPath(dir, outputName),
|
|
6848
|
+
onProgress: (message) => motionJobs.progress(job, message.phase, message.progress) })
|
|
6849
|
+
motionJobs.attach(job, handle)
|
|
6850
|
+
const renderLine = handle.render ? describeRenderSettings(handle.render) : ''
|
|
6851
|
+
return { summary: '已开始渲染' + (kind === 'pilot' ? '样片' : '') + '(' + job.id + ')' + (renderLine ? ' · ' + renderLine : '')
|
|
6852
|
+
+ gateWarnings.map((warning) => ' ⚠ ' + warning).join('') + ',用 motion_render status 查进度',
|
|
6853
|
+
details: JSON.stringify({ jobId: job.id, kind, output: outputPath(dir, outputName),
|
|
6854
|
+
...(handle.render ? { render: handle.render } : {}),
|
|
6855
|
+
...(engineGate ? { engine: { required: engineGate.required, detected: engineGate.detected, missing: engineGate.missing, deviation: engineGate.deviation } } : {}),
|
|
6856
|
+
...(qualityGate ? { quality: { fonts: qualityGate.fonts, warnings: qualityGate.warnings } } : {}),
|
|
6857
|
+
next: kind === 'pilot' ? '用主节点和 jobId 查 status;完成后在同一画布页创建或复用独立样片视频节点,再 motion_render {action:"apply",nodeId:"样片节点 ID",jobId:"' + job.id + '",source:"pilot"} 回填并告知用户。' : '渲完 apply 到主视频节点' }) }
|
|
6858
|
+
},
|
|
6859
|
+
})))
|
|
6860
|
+
|
|
6861
|
+
// 帧检:把"片子有没有毛病"变成一张带帧号的清单,而不是靠眼睛。
|
|
6862
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6863
|
+
name: 'motion_qa',
|
|
6864
|
+
description: '成片帧检:不靠眼睛,靠像素和响度。inspect 立刻检一遍(默认检工程里的 .out/film.mp4,也可以指定 file):帧数对不对、有没有黑场、有没有静止太久、有没有单帧闪烁、有没有空白帧、画面有没有顶到边、音轨有没有削波。report 读上次的结果。每条问题都带帧号和镜头号,能直接回去改对应场景。',
|
|
6865
|
+
parameters: {
|
|
6866
|
+
action: { type: 'string', enum: ['inspect', 'report'], required: true },
|
|
6867
|
+
path: { type: 'string' }, nodeId: { type: 'string' }, page: { type: 'number' },
|
|
6868
|
+
file: { type: 'string', description: '要检的片子:绝对路径,或工程目录里的相对路径(默认 .out/film.mp4)' },
|
|
6869
|
+
freezeSeconds: { type: 'number', description: '静止多久算一笔记账,默认 1.2 秒' },
|
|
6870
|
+
},
|
|
6871
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6872
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6873
|
+
async execute(args, exec) {
|
|
6874
|
+
const scope = await motionScope(args, exec)
|
|
6875
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6876
|
+
const node = args.nodeId ? loaded.doc.nodes.find((item) => item.id === args.nodeId) : loaded.doc.nodes.find(isMotionNode)
|
|
6877
|
+
if (!node) throw new Error('找不到视频节点')
|
|
6878
|
+
const dir = projectDirOf(node, scope)
|
|
6879
|
+
if (!existsSync(dir)) throw new Error('工程还没建:先 motion_project create')
|
|
6880
|
+
if (args.action === 'report') {
|
|
6881
|
+
const saved = readQaReport(dir)
|
|
6882
|
+
if (!saved) throw new Error('还没有帧检结果:先用 motion_qa {action:"inspect"} 跑一遍')
|
|
6883
|
+
return { summary: '帧检 ' + saved.verdict + '(' + saved.summary.fail + ' fail / ' + saved.summary.warn + ' warn)',
|
|
6884
|
+
details: JSON.stringify(saved) }
|
|
6885
|
+
}
|
|
6886
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6887
|
+
const film = args.file ? workspacePath(scope, isAbsolute(args.file) ? args.file : joinPath(dir, args.file)) : workspacePath(scope, outputPath(dir, 'film.mp4'))
|
|
6888
|
+
if (!existsSync(film)) throw new Error('没有可检的片子:' + film + '(先 motion_render start 出一版)')
|
|
6889
|
+
const timeline = readTimeline(dir)
|
|
6890
|
+
const report = await runFrameQa({ dir, file: film, timeline, options: { freezeSeconds: args.freezeSeconds } })
|
|
6891
|
+
const problems = report.checks.filter((check) => check.level === 'fail' || check.level === 'warn')
|
|
6892
|
+
const head = problems.length
|
|
6893
|
+
? problems.slice(0, 4).map((check) => check.level + ':' + check.message).join(';')
|
|
6894
|
+
: '没发现问题'
|
|
6895
|
+
return { summary: '帧检 ' + report.verdict + '(' + report.frames + ' 帧 / ' + report.durationSeconds + 's)—— ' + head,
|
|
6896
|
+
details: JSON.stringify(report) }
|
|
6897
|
+
},
|
|
6898
|
+
})))
|
|
6899
|
+
|
|
6900
|
+
// 剪映导出:把成片拆成"还能继续剪"的素材包。
|
|
6901
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6902
|
+
name: 'motion_export',
|
|
6903
|
+
description: '把工程导出成剪映可继续剪的素材包:逐镜头无声音轨 clips/、原音轨 audio/(BGM 与音效分开)、shots.json / audio.json / README.md,有字幕数据时再给 subtitles.srt,draft:true 另写一份 draft_content.json 草稿。run 导出,list 看导出过哪些。镜头按时间轴精确切(重编码,不靠关键帧),cut 点不会偏。',
|
|
6904
|
+
parameters: {
|
|
6905
|
+
action: { type: 'string', enum: ['run', 'list'], required: true },
|
|
6906
|
+
path: { type: 'string' }, nodeId: { type: 'string' }, page: { type: 'number' },
|
|
6907
|
+
name: { type: 'string', description: '导出目录名,默认 jianying' },
|
|
6908
|
+
draft: { type: 'boolean', description: '另写一份 draft_content.json(剪映草稿结构,未在剪映里实测过)' },
|
|
6909
|
+
subtitles: { type: 'json', description: '字幕数组 [{startFrame,endFrame,text}];工程 timeline 里有 subtitles 就不用传' },
|
|
6910
|
+
file: { type: 'string', description: '当成片对照的 mp4,默认 .out/film.mp4' },
|
|
6911
|
+
},
|
|
6912
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6913
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6914
|
+
async execute(args, exec) {
|
|
6915
|
+
const scope = await motionScope(args, exec)
|
|
6916
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6917
|
+
const node = args.nodeId ? loaded.doc.nodes.find((item) => item.id === args.nodeId) : loaded.doc.nodes.find(isMotionNode)
|
|
6918
|
+
if (!node) throw new Error('找不到视频节点')
|
|
6919
|
+
const dir = projectDirOf(node, scope)
|
|
6920
|
+
if (!existsSync(dir)) throw new Error('工程还没建:先 motion_project create')
|
|
6921
|
+
if (args.action === 'list') {
|
|
6922
|
+
return { summary: '导出过的素材包', details: JSON.stringify(listExports(dir)) }
|
|
6923
|
+
}
|
|
6924
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6925
|
+
const timeline = readTimeline(dir)
|
|
6926
|
+
const result = await exportPackage({ dir, timeline, name: args.name || 'jianying', draft: Boolean(args.draft),
|
|
6927
|
+
subtitles: args.subtitles, film: args.file ? workspacePath(scope, isAbsolute(args.file) ? args.file : joinPath(dir, args.file)) : undefined })
|
|
6928
|
+
return { summary: '已导出 ' + result.shots.length + ' 镜 / ' + result.stems.length + ' 条音轨到 ' + result.folder
|
|
6929
|
+
+ (result.subtitles ? '(含字幕 ' + result.subtitles + ' 条)' : '')
|
|
6930
|
+
+ (result.missing.length ? ',缺 ' + result.missing.length + ' 个音频文件' : ''),
|
|
6931
|
+
details: JSON.stringify(result) }
|
|
6932
|
+
},
|
|
6933
|
+
})))
|
|
6934
|
+
|
|
6935
|
+
const motionProjectOf = async (args, exec) => {
|
|
6936
|
+
const scope = await motionScope(args, exec)
|
|
6937
|
+
const loaded = await loadDoc(scope.path, scope.sessionId, args.page)
|
|
6938
|
+
const node = loaded.doc.nodes.find((item) => item.id === args.nodeId)
|
|
6939
|
+
if (!node) throw new Error('找不到视频节点 ' + args.nodeId)
|
|
6940
|
+
return { scope, loaded, node, dir: projectDirOf(node, scope) }
|
|
6941
|
+
}
|
|
6942
|
+
const motionSourceFile = (args, scope, doc) => {
|
|
6943
|
+
if (typeof args.file === 'string' && args.file)
|
|
6944
|
+
return workspacePath(scope, isAbsolute(args.file) ? args.file : joinPath(dirnamePath(scope.drawioAbs), args.file))
|
|
6945
|
+
const node = doc.nodes.find((item) => item.id === args.sourceNodeId)
|
|
6946
|
+
if (!node) throw new Error('请给 file(工作区里的图片路径)或 sourceNodeId(画布上的素材节点)')
|
|
6947
|
+
if (!node.data?.drawaiMedia || node.data.mediaKind !== 'raster') throw new Error('这个节点不是位图素材')
|
|
6948
|
+
const clipDir = clipDirFor(scope.drawioAbs, node.data.mediaClip)
|
|
6949
|
+
const found = findSourceFile(clipDir, readManifest(clipDir))
|
|
6950
|
+
if (!found) throw new Error('这个素材节点没有源文件,请重新导入')
|
|
6951
|
+
return workspacePath(scope, found.abs)
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6954
|
+
// motion_assets 真素材进工程(原字节 + 尺寸 + 色板),场景用 staticFile 取
|
|
6955
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
6956
|
+
name: 'motion_assets',
|
|
6957
|
+
description: '视频工程素材:list 查看工程素材;library 按 query(如 纸张、颗粒、钢琴)或 category 搜索插件自带纹理及乐器采样;use 用 assetId 按需导入,返回 file,图片用 staticFile(file),音频须另用 motion_audio add 上时间轴。素材库是可选来源,用户自带素材优先,采样是单音而非完整配乐;使用前查看许可。gather 按站点收集品牌素材、配色与字体,collect 扫单页;capture 收本地素材;fetch 下载原图;crop 裁切;page 截网页;palette 读色板。',
|
|
6958
|
+
parameters: {
|
|
6959
|
+
action: { type: 'string', enum: ['list', 'library', 'use', 'capture', 'gather', 'collect', 'fetch', 'crop', 'palette', 'page'], required: true, description: 'library 浏览插件自带纹理与乐器采样;use 按 assetId 将选定素材导入工程;list 查看工程内素材' },
|
|
6960
|
+
path: { type: 'string', description: '画布路径,省略时用当前画布' }, nodeId: { type: 'string', required: true, description: '视频节点 id(决定哪个工程)' },
|
|
6961
|
+
page: { type: 'number' },
|
|
6962
|
+
file: { type: 'string', description: '工作区里的图片路径(相对画布目录)' },
|
|
6963
|
+
sourceNodeId: { type: 'string', description: '画布上的位图素材节点 id(与 file 二选一)' },
|
|
6964
|
+
name: { type: 'string', description: '落地文件名(不含扩展名)' },
|
|
6965
|
+
caption: { type: 'string', description: '这条素材是什么,写进 manifest 供后续参考' },
|
|
6966
|
+
assetId: { type: 'string', description: 'use:从 library 返回结果中选择的素材 id' },
|
|
6967
|
+
query: { type: 'string', description: 'library:按文件名或中英文类别筛选' },
|
|
6968
|
+
category: { type: 'string', description: 'library:精确类别(如 piano、paper、audio、image)' },
|
|
6969
|
+
x: { type: 'number' }, y: { type: 'number' }, w: { type: 'number' }, h: { type: 'number', description: 'crop 的区域(像素)' },
|
|
6970
|
+
html: { type: 'string', description: 'page:直接给 HTML 源文本(推荐,里面就是待截的界面)' },
|
|
6971
|
+
url: { type: 'string', description: 'gather:站点首页网址(会顺带扫导航里的内页);collect:要收集素材的页面网址(http(s));fetch:要下载的素材地址(官方 logo 的 .svg/.png 直链);page:要截的网址,http(s) 或 file:' },
|
|
6972
|
+
urls: { type: 'array', items: { type: 'string' }, description: 'gather:直接指定要扫的多个页面(产品页/文档页/定价页各一张,最多 6 页)' },
|
|
6973
|
+
brand: { type: 'string', description: 'gather:品牌名或域名(如 "github" / "github.com"),用来补 Simple Icons / Clearbit / 高清 favicon 三条 logo 来源' },
|
|
6974
|
+
pages: { type: 'number', description: 'gather:最多扫几个页面,默认 4(上限 6)' },
|
|
6975
|
+
limit: { type: 'number', description: 'gather 默认 12、collect 默认 8:最多下载几张' },
|
|
6976
|
+
minWidth: { type: 'number', description: '候选图片的最小宽度,默认 240' },
|
|
6977
|
+
dryRun: { type: 'boolean', description: 'gather/collect:只扫描不下载,先看候选清单' },
|
|
6978
|
+
width: { type: 'number', description: 'page:视口宽,默认 1440' },
|
|
6979
|
+
height: { type: 'number', description: 'page:视口高,默认 900' },
|
|
6980
|
+
scale: { type: 'number', description: 'page:像素比,默认 2(截出来是 2 倍图)' },
|
|
6981
|
+
wait: { type: 'number', description: 'page:等页面稳定的毫秒数,默认 1200' },
|
|
6982
|
+
},
|
|
6983
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
6984
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
6985
|
+
async execute(args, exec) {
|
|
6986
|
+
const { scope, loaded, dir } = await motionProjectOf(args, exec)
|
|
6987
|
+
if (args.action === 'list')
|
|
6988
|
+
return { summary: '工程素材 ' + listAssets(dir).length + ' 个', details: JSON.stringify(listAssets(dir).map(({ path: _path, ...rest }) => rest)) }
|
|
6989
|
+
if (args.action === 'library') {
|
|
6990
|
+
await ensureMotionAssets('查插件素材库')
|
|
6991
|
+
const items = listMediaLibrary({ query: args.query, category: args.category })
|
|
6992
|
+
return { summary: '插件素材库 ' + items.length + ' 个', details: JSON.stringify(items) }
|
|
6993
|
+
}
|
|
6994
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
6995
|
+
if (args.action === 'use') {
|
|
6996
|
+
await ensureMotionAssets('导入插件素材')
|
|
6997
|
+
const entry = await importMediaLibrary(dir, args.assetId)
|
|
6998
|
+
return { summary: '已导入插件素材 ' + entry.file + (entry.integrity?.warnings?.length ? '(素材校验有提示,详见返回信息)' : ''), details: JSON.stringify({ ...entry,
|
|
6999
|
+
use: entry.kind === 'audio' ? 'motion_audio ' + JSON.stringify({ action: 'add', nodeId: args.nodeId, file: entry.file, role: 'sfx', at: 0 }) : 'staticFile(' + JSON.stringify(entry.file) + ')' }) }
|
|
7000
|
+
}
|
|
7001
|
+
if (args.action === 'page') {
|
|
7002
|
+
if (typeof args.url === 'string' && /^file:/i.test(args.url)) workspacePath(scope, fileURLToPath(args.url))
|
|
7003
|
+
const entry = await capturePage(dir, { html: args.html, url: args.url, name: args.name, caption: args.caption,
|
|
7004
|
+
width: args.width, height: args.height, scale: args.scale, wait: args.wait })
|
|
7005
|
+
return { summary: '已截屏 ' + entry.file + '(' + entry.width + '×' + entry.height + ')',
|
|
7006
|
+
details: JSON.stringify({ ...entry, use: 'staticFile("' + entry.file + '")', next: '场景里 <Img src={staticFile("' + entry.file + '")} />;截歪了就调 width/height/scale 再截一次' }) }
|
|
7007
|
+
}
|
|
7008
|
+
if (args.action === 'gather') {
|
|
7009
|
+
const gathered = await gatherAssets(dir, { url: args.url, urls: args.urls, brand: args.brand, pages: args.pages,
|
|
7010
|
+
limit: args.limit, minWidth: args.minWidth, width: args.width, dryRun: args.dryRun })
|
|
7011
|
+
const count = gathered.dryRun ? (gathered.candidates || []).length : (gathered.downloaded || []).length
|
|
7012
|
+
return { summary: (gathered.dryRun ? '扫到 ' : '已收集 ') + count + (gathered.dryRun ? ' 个候选' : ' 张素材')
|
|
7013
|
+
+ ' · ' + (gathered.pages || []).length + ' 页 · 配色 ' + (gathered.colors || []).length + ' 个 · 字体 ' + (gathered.fonts || []).length + ' 个',
|
|
7014
|
+
details: JSON.stringify(gathered) }
|
|
7015
|
+
}
|
|
7016
|
+
if (args.action === 'collect') {
|
|
7017
|
+
if (!args.url) throw new Error('collect 需要 url')
|
|
7018
|
+
const collected = await collectPageAssets(dir, args.url, { limit: args.limit, minWidth: args.minWidth, width: args.width, dryRun: args.dryRun })
|
|
7019
|
+
return { summary: (collected.dryRun ? '扫描到 ' : '已收集 ') + (collected.downloaded || collected.candidates || []).length
|
|
7020
|
+
+ (collected.dryRun ? ' 个候选' : ' 张素材') + (collected.screenshot ? ' + 视口截图' : '') + ':' + String(collected.title || args.url).slice(0, 60),
|
|
7021
|
+
details: JSON.stringify(collected) }
|
|
7022
|
+
}
|
|
7023
|
+
if (args.action === 'fetch') {
|
|
7024
|
+
if (!args.url) throw new Error('fetch 需要 url')
|
|
7025
|
+
const entry = await fetchAsset(dir, args.url, { name: args.name, caption: args.caption })
|
|
7026
|
+
return { summary: '已下载素材 ' + entry.file + (entry.kind === 'vector' ? '(矢量)' : ''),
|
|
7027
|
+
details: JSON.stringify({ ...entry, use: 'staticFile("' + entry.file + '")',
|
|
7028
|
+
next: entry.kind === 'vector'
|
|
7029
|
+
? '矢量素材放进 <Img src={staticFile("' + entry.file + '")} /> 即可,放大不糊;比例、配色、留白按品牌规范来,别改'
|
|
7030
|
+
: '场景里 <Img src={staticFile("' + entry.file + '")} />;改完 motion_preview 看图' }) }
|
|
7031
|
+
}
|
|
7032
|
+
const source = motionSourceFile(args, scope, loaded.doc)
|
|
7033
|
+
if (args.action === 'palette') {
|
|
7034
|
+
const colors = await colorsOf(source)
|
|
7035
|
+
return { summary: '色板:' + (colors.average || '未知'), details: JSON.stringify({ file: source, ...colors }) }
|
|
7036
|
+
}
|
|
7037
|
+
if (args.action === 'crop') {
|
|
7038
|
+
const entry = await cropAsset(dir, source, { name: args.name, caption: args.caption, x: args.x, y: args.y, w: args.w, h: args.h })
|
|
7039
|
+
return { summary: '已裁切素材 ' + entry.file, details: JSON.stringify({ ...entry, use: 'staticFile("' + entry.file + '")' }) }
|
|
7040
|
+
}
|
|
7041
|
+
const entry = await captureAsset(dir, source, { name: args.name, caption: args.caption })
|
|
7042
|
+
return { summary: '已捕获素材 ' + entry.file + '(' + entry.width + '×' + entry.height + ')',
|
|
7043
|
+
details: JSON.stringify({ ...entry, use: 'staticFile("' + entry.file + '")', next: '场景里 import { staticFile } from "remotion",再用 staticFile 引用;改完 motion_preview 看图' }) }
|
|
7044
|
+
},
|
|
7045
|
+
})))
|
|
7046
|
+
|
|
7047
|
+
// motion_sfx 音效采购:Freesound 搜索/下载与素材索引
|
|
7048
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
7049
|
+
name: 'motion_sfx',
|
|
7050
|
+
description: '音效采购(Freesound)。search 按英文关键词搜,返回时长与作者;get 按 id 下载 HQ mp3 预览到 <工程>/assets/sfx/,量峰值与有效区间,维护一份 assets/audio-manifest.json;list 看已下载的音效。下载完用 motion_audio {action:"add", file:"sfx/xxx.mp3", role:"sfx"} 挂到轴上。',
|
|
7051
|
+
parameters: {
|
|
7052
|
+
action: { type: 'string', enum: ['search', 'get', 'list'], required: true },
|
|
7053
|
+
path: { type: 'string' }, nodeId: { type: 'string', required: true, description: '视频节点 id' },
|
|
7054
|
+
page: { type: 'number' },
|
|
7055
|
+
query: { type: 'string', description: 'search:英文查询词,短而具体更好("paper whoosh"、"ui click")' },
|
|
7056
|
+
minDuration: { type: 'number', description: 'search:最短秒数,默认 0' },
|
|
7057
|
+
maxDuration: { type: 'number', description: 'search:最长秒数' },
|
|
7058
|
+
limit: { type: 'number', description: 'search:最多返回几条,默认 8' },
|
|
7059
|
+
ids: { type: 'json', description: 'get:音效 id 数组,例如 ["fs:12345"]' },
|
|
7060
|
+
},
|
|
7061
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
7062
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
7063
|
+
async execute(args, exec) {
|
|
7064
|
+
const { scope, dir } = await motionProjectOf(args, exec)
|
|
7065
|
+
if (args.action === 'list') {
|
|
7066
|
+
const items = listSfx(dir)
|
|
7067
|
+
return { summary: '工程里已有 ' + items.length + ' 条音效', details: JSON.stringify(items) }
|
|
7068
|
+
}
|
|
7069
|
+
if (args.action === 'search') {
|
|
7070
|
+
const found = await searchSfx({ query: args.query, minDuration: args.minDuration, maxDuration: args.maxDuration, limit: args.limit })
|
|
7071
|
+
return { summary: '找到 ' + found.results.length + ' 条',
|
|
7072
|
+
details: JSON.stringify({ ...found, next: '挑一条再 motion_sfx {action:"get", ids:["fs:xxxx"]};想先听可以按 pageUrl 打开' }) }
|
|
7073
|
+
}
|
|
7074
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
7075
|
+
const result = await getSfx(dir, args.ids)
|
|
7076
|
+
const ok = result.results.filter((item) => item.ok)
|
|
7077
|
+
const bad = result.results.filter((item) => !item.ok)
|
|
7078
|
+
return { summary: '下载 ' + ok.length + ' 条' + (bad.length ? ',失败 ' + bad.length + ' 条' : ''),
|
|
7079
|
+
details: JSON.stringify({
|
|
7080
|
+
ok: ok.map((item) => ({ id: item.asset.id, file: item.asset.file, title: item.asset.title, license: item.asset.license,
|
|
7081
|
+
seconds: item.asset.analysis.seconds, peakDb: item.asset.analysis.peakDb, rmsDb: item.asset.analysis.rmsDb,
|
|
7082
|
+
suggestedGainDb: item.asset.analysis.suggestedGainDb,
|
|
7083
|
+
use: 'motion_audio {action:"add", file:"' + item.asset.file + '", role:"sfx", gainDb:' + item.asset.analysis.suggestedGainDb + '}' })),
|
|
7084
|
+
failed: bad,
|
|
7085
|
+
}) }
|
|
7086
|
+
},
|
|
7087
|
+
})))
|
|
7088
|
+
|
|
7089
|
+
// motion_audio 音频轴:加/删/列音轨,量节拍,把切镜和目标音钉到拍子上
|
|
7090
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
7091
|
+
name: 'motion_audio',
|
|
7092
|
+
description: '音频轴工具。list 列出时间轴上的音轨;add 把一条音频放到指定秒(默认写进 <工程>/assets/,素材没进工程会自动捕获);remove 按 cueId 删;analyze 量 BPM/网格/重音(返回 bpm、grid 残差、hits 三分类、peakLag);apply 写 beats.json 并把切点吸到拍子上,给 cueId 时改把这条音轨钉到拍子(自动扣掉音源峰值滞后与输出偏移)。bpm 为 null 说明量不出稳定网格——不要硬对。',
|
|
7093
|
+
parameters: {
|
|
7094
|
+
action: { type: 'string', enum: ['list', 'add', 'remove', 'analyze', 'apply'], required: true },
|
|
7095
|
+
path: { type: 'string' }, nodeId: { type: 'string', required: true, description: '视频节点 id' },
|
|
7096
|
+
page: { type: 'number' },
|
|
7097
|
+
file: { type: 'string', description: '音频文件(绝对路径,或相对画布目录);也可以直接写 <工程>/assets/ 下的文件名' },
|
|
7098
|
+
audioNodeId: { type: 'string', description: '画布上的音频节点 id(与 file 二选一)' },
|
|
7099
|
+
at: { type: 'number', description: 'add:放到第几秒(默认 0)' },
|
|
7100
|
+
duration: { type: 'number', description: 'add:播放多少秒(默认取素材时长)' },
|
|
7101
|
+
role: { type: 'string', enum: ['bgm', 'voice', 'sfx'], description: 'add:音轨角色,默认 sfx' },
|
|
7102
|
+
gainDb: { type: 'number', description: 'add:增益 dB(bgm 默认 -10)' },
|
|
7103
|
+
fadeIn: { type: 'number', description: 'add:淡入秒数' },
|
|
7104
|
+
fadeOut: { type: 'number', description: 'add:淡出秒数' },
|
|
7105
|
+
loop: { type: 'boolean', description: 'add:循环' },
|
|
7106
|
+
duck: { type: 'boolean', description: 'add:旁白区间自动压低(bgm 默认 true)' },
|
|
7107
|
+
cueId: { type: 'string', description: 'apply:把这条音轨钉到最近的拍子' },
|
|
7108
|
+
snap: { type: 'boolean', description: 'apply 时是否改时间轴,默认 true' },
|
|
7109
|
+
tolerance: { type: 'number', description: '吸附容差(帧),默认 4' },
|
|
7110
|
+
},
|
|
7111
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
7112
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
7113
|
+
async execute(args, exec) {
|
|
7114
|
+
const { scope, loaded, dir } = await motionProjectOf(args, exec)
|
|
7115
|
+
const timeline = readTimeline(dir)
|
|
7116
|
+
const fps = timeline.fps
|
|
7117
|
+
|
|
7118
|
+
if (timeline.sync && args.action === 'apply') throw new Error('该工程已统一绑定,请通过 motion_sync bind 更新,避免旧吸附流程覆盖锚点')
|
|
7119
|
+
if (args.action === 'list') {
|
|
7120
|
+
const cues = readTrack(timeline)
|
|
7121
|
+
return { summary: cues.length ? '音频轨 ' + cues.length + ' 条' : '时间轴上还没有音频', details: describeTrack(cues, fps) }
|
|
7122
|
+
}
|
|
7123
|
+
|
|
7124
|
+
if (args.action === 'add') {
|
|
7125
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
7126
|
+
let name = ''
|
|
7127
|
+
if (typeof args.file === 'string' && args.file) {
|
|
7128
|
+
const candidate = isAbsolute(args.file) ? args.file : joinPath(dirnamePath(scope.drawioAbs), args.file)
|
|
7129
|
+
if (existsSync(candidate)) {
|
|
7130
|
+
const entry = await captureAsset(dir, workspacePath(scope, candidate), { caption: args.file })
|
|
7131
|
+
name = entry.file
|
|
7132
|
+
} else name = args.file.replace(/\\/g, '/')
|
|
7133
|
+
} else {
|
|
7134
|
+
const audioNode = loaded.doc.nodes.find((item) => item.id === args.audioNodeId)
|
|
7135
|
+
if (!audioNode?.data?.audioClip) throw new Error('请给 file(音频文件),或 audioNodeId 指向一个已用 audio_asset 导入的音频节点')
|
|
7136
|
+
const entry = await captureAsset(dir, frozenAudioFile(scope.drawioAbs, audioNode.data.audioClip).file, { caption: 'audio node ' + audioNode.id })
|
|
7137
|
+
name = entry.file
|
|
7138
|
+
}
|
|
7139
|
+
const assetFile = workspacePath(scope, assetPath(dir, name))
|
|
7140
|
+
if (!existsSync(assetFile)) throw new Error('工程 assets/ 里没有这个音频:' + name)
|
|
7141
|
+
const probe = await probeAudio(assetFile)
|
|
7142
|
+
const seconds = Number(args.duration) || probe.seconds
|
|
7143
|
+
const at = Math.max(0, Number(args.at) || 0)
|
|
7144
|
+
const fromFrame = Math.round(at * fps)
|
|
7145
|
+
const role = args.role || 'sfx'
|
|
7146
|
+
// 增益的口径:显式给的最优先;没给就吃 motion_sfx get 量出来的建议值;再没有才用角色默认。
|
|
7147
|
+
const gain = resolveGainDb({ explicitGain: args.gainDb, manifestGain: suggestedGainOf(readAudioManifest(dir), name), role, peakDb: probe.samplePeakDb })
|
|
7148
|
+
const level = cueLevel({ peakDb: probe.samplePeakDb, gainDb: gain.gainDb })
|
|
7149
|
+
const span = Math.round(seconds * fps)
|
|
7150
|
+
const overlaps = readTrack(timeline).filter((item) => item.from < fromFrame + span && item.from + item.durationInFrames > fromFrame).length
|
|
7151
|
+
const next = addCue(timeline, {
|
|
7152
|
+
file: name, role, from: fromFrame,
|
|
7153
|
+
durationInFrames: span, gainDb: gain.gainDb,
|
|
7154
|
+
fadeIn: Math.round((Number(args.fadeIn) || 0) * fps), fadeOut: Math.round((Number(args.fadeOut) || 0) * fps),
|
|
7155
|
+
loop: args.loop, duck: args.duck,
|
|
7156
|
+
})
|
|
7157
|
+
writeTimeline(dir, next)
|
|
7158
|
+
const cues = readTrack(next)
|
|
7159
|
+
const cue = cues.find((item) => item.file === name && item.role === (args.role || 'sfx') && item.from === fromFrame) || cues[cues.length - 1]
|
|
7160
|
+
const copies = cues.filter((item) => item.file === name).length
|
|
7161
|
+
return { summary: '已加音轨 ' + name + ' @ ' + at.toFixed(2) + 's(' + seconds.toFixed(2) + 's),' + cue.gainDb + 'dB'
|
|
7162
|
+
+ (copies > 1 ? ',这个文件在轴上有 ' + copies + ' 条' : ''),
|
|
7163
|
+
details: describeLevel({ cue, gain, level, overlaps }) + '\n' + JSON.stringify({ cue, track: cues, fps }) }
|
|
7164
|
+
}
|
|
7165
|
+
|
|
7166
|
+
if (args.action === 'remove') {
|
|
7167
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
7168
|
+
const next = removeCue(timeline, args.cueId)
|
|
7169
|
+
writeTimeline(dir, next)
|
|
7170
|
+
return { summary: '已删除音轨 ' + args.cueId, details: describeTrack(readTrack(next), fps) }
|
|
7171
|
+
}
|
|
7172
|
+
|
|
7173
|
+
let file
|
|
7174
|
+
if (typeof args.file === 'string' && args.file) file = workspacePath(scope, isAbsolute(args.file) ? args.file : joinPath(dirnamePath(scope.drawioAbs), args.file))
|
|
7175
|
+
else {
|
|
7176
|
+
const audioNode = loaded.doc.nodes.find((item) => item.id === args.audioNodeId)
|
|
7177
|
+
if (!audioNode?.data?.audioClip) throw new Error('请给 file(音频文件路径),或 audioNodeId 指向一个已用 audio_asset 导入的音频节点')
|
|
7178
|
+
file = frozenAudioFile(scope.drawioAbs, audioNode.data.audioClip).file
|
|
7179
|
+
}
|
|
7180
|
+
const beats = await analyzeBeats(file)
|
|
7181
|
+
const beatFrames = beats.beats.map((beat) => Math.round(beat.at * fps))
|
|
7182
|
+
const peakLagFrames = Math.round((beats.peakLag || 0) * fps)
|
|
7183
|
+
if (args.action === 'analyze')
|
|
7184
|
+
return { summary: beats.grid
|
|
7185
|
+
? '约 ' + beats.bpm + ' BPM,网格残差 ' + beats.grid.residualMs + 'ms,' + beats.beats.length + ' 个重音'
|
|
7186
|
+
: (beats.bpm ? '约 ' + beats.bpm + ' BPM 但没拟合出稳定网格(' + (beats.note || '重音不在等距网格上') + ')' : '没量到稳定节拍'),
|
|
7187
|
+
details: JSON.stringify({ file, ...beats, fps, beatFrames, peakLagFrames }) }
|
|
7188
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
7189
|
+
writeFileSync(joinPath(dir, 'beats.json'), JSON.stringify({ ...beats, fps, beatFrames, peakLagFrames }, null, 2))
|
|
7190
|
+
const moved = []
|
|
7191
|
+
const pinned = []
|
|
7192
|
+
if (args.cueId) {
|
|
7193
|
+
const cue = readTrack(timeline).find((item) => item.id === args.cueId)
|
|
7194
|
+
if (!cue) throw new Error('没有这条音轨:' + args.cueId)
|
|
7195
|
+
const target = snapFrame(cue.from, beats.beats.map((beat) => ({ at: beat.at, strength: beat.strength })), fps, Number(args.tolerance) || 4)
|
|
7196
|
+
const pinnedFrom = cuePinnedAt(target, peakLagFrames)
|
|
7197
|
+
if (pinnedFrom !== cue.from) pinned.push({ cue: cue.id, from: cue.from, to: pinnedFrom, target, peakLagFrames })
|
|
7198
|
+
timeline.audio = readTrack(timeline).map((item) => item.id === cue.id ? { ...item, from: pinnedFrom } : item)
|
|
7199
|
+
}
|
|
7200
|
+
if (args.snap !== false && beats.beats.length) {
|
|
7201
|
+
const tolerance = Number(args.tolerance) || 4
|
|
7202
|
+
let previous = 0
|
|
7203
|
+
for (let index = 1; index < timeline.shots.length; index++) {
|
|
7204
|
+
const shot = timeline.shots[index]
|
|
7205
|
+
const limit = timeline.durationInFrames - (timeline.shots.length - index) * 6
|
|
7206
|
+
const target = Math.max(previous + 6, Math.min(limit, snapFrame(shot.startFrame, beats.beats, fps, tolerance)))
|
|
7207
|
+
if (target !== shot.startFrame) moved.push({ shot: shot.id, from: shot.startFrame, to: target })
|
|
7208
|
+
previous = target
|
|
7209
|
+
shot.startFrame = target
|
|
7210
|
+
}
|
|
7211
|
+
for (let index = 0; index < timeline.shots.length; index++) {
|
|
7212
|
+
const end = index + 1 < timeline.shots.length ? timeline.shots[index + 1].startFrame : timeline.durationInFrames
|
|
7213
|
+
timeline.shots[index].durationFrames = end - timeline.shots[index].startFrame
|
|
7214
|
+
}
|
|
7215
|
+
}
|
|
7216
|
+
if (moved.length || pinned.length) writeTimeline(dir, timeline)
|
|
7217
|
+
return { summary: '已写入 beats.json' + (moved.length ? ',' + moved.length + ' 个切点吸到拍子上' : '') + (pinned.length ? ',音轨钉到第 ' + pinned[0].target + ' 帧' : ''),
|
|
7218
|
+
details: JSON.stringify({ bpm: beats.bpm, grid: beats.grid, confidence: beats.confidence, beats: beats.beats.length, moved, pinned, timeline: moved.length || pinned.length ? timeline : undefined }) }
|
|
7219
|
+
},
|
|
7220
|
+
})))
|
|
7221
|
+
|
|
7222
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
7223
|
+
name: 'motion_sync',
|
|
7224
|
+
description: '音乐拍点→切镜/动作→音效峰值统一绑定。bind 替换绑定表并分析音乐;list 查看解析结果;clear 解除绑定、保留当前排期。anchors:[{id,type:"cut"|"action",shotId,beat?:0起拍号,at?:源音乐秒,name?:动作名,visual?:{metric:"change"|"brightness",roi:{x,y,w,h}},sfx?:[{cueId,peakSeconds:素材源峰值秒}]}]。beat/at 二选一;动作场景接收 anchors,使用 anchors.<name> 本镜局部帧写关键帧。动作验收要选 ROI/metric,切镜默认测帧差。绑定后 motion_audio_verify 对照成片。音乐需要非循环音轨,音乐 from/trimStart 参与换算。',
|
|
7225
|
+
parameters: {
|
|
7226
|
+
action: {type:'string',enum:['bind','list','clear'],required:true},
|
|
7227
|
+
nodeId: {type:'string',required:true}, path:{type:'string'},page:{type:'number'},
|
|
7228
|
+
musicCueId:{type:'string'},anchors:{type:'json'},
|
|
7229
|
+
outputOffsetSeconds:{type:'number',description:'具体渲染管线实测音轨延迟(秒),默认0;验收报告给出多点测得的偏移参考'},
|
|
7230
|
+
toleranceFrames:{type:'number',description:'音画及音效误差容差,默认2帧,最大3帧'},
|
|
7231
|
+
},
|
|
7232
|
+
output: {schema:{type:'object',additionalProperties:false,properties:{summary:{type:'string',required:true},details:{type:'string',required:true}}},
|
|
7233
|
+
render:(_args,value)=>[{type:'text',text:value.summary+'\n'+value.details}]},
|
|
7234
|
+
async execute(args, exec) {
|
|
7235
|
+
const {scope,dir}=await motionProjectOf(args,exec)
|
|
7236
|
+
let timeline=readTimeline(dir)
|
|
7237
|
+
if(args.action==='list') return {summary:'音乐同步绑定',details:JSON.stringify(timeline.sync||null)}
|
|
7238
|
+
if(policyFor(scope.sessionId)?.mode==='read-only')throw new Error('当前会话为只读')
|
|
7239
|
+
if(args.action==='clear') {
|
|
7240
|
+
delete timeline.sync
|
|
7241
|
+
for(const shot of timeline.shots)delete shot.anchors
|
|
7242
|
+
} else {
|
|
7243
|
+
const cue=timeline.audio.find(item=>item.id===args.musicCueId)
|
|
7244
|
+
if(!cue)throw new Error('musicCueId 不存在;先 motion_audio add/list')
|
|
7245
|
+
const analysis=await analyzeBeats(assetPath(dir,cue.file))
|
|
7246
|
+
const tolerance=args.toleranceFrames??2
|
|
7247
|
+
if(!Number.isFinite(tolerance)||tolerance<0||tolerance>3)throw new Error('toleranceFrames 必须在0–3内')
|
|
7248
|
+
timeline.sync={version:1,musicCueId:cue.id,analysis,analysisSourceHash:filmHash(assetPath(dir,cue.file)),anchors:args.anchors,outputOffsetSeconds:args.outputOffsetSeconds??0,toleranceFrames:tolerance}
|
|
7249
|
+
timeline=resolveSync(timeline)
|
|
7250
|
+
}
|
|
7251
|
+
writeTimeline(dir,timeline)
|
|
7252
|
+
return {summary:args.action==='clear'?'已解除音乐绑定':'已绑定音乐、画面与音效;场景用 anchors.<name> 驱动动作关键帧',details:JSON.stringify(timeline.sync||{})}
|
|
7253
|
+
},
|
|
7254
|
+
})))
|
|
7255
|
+
|
|
7256
|
+
// motion_audio verify 渲后回测:成片里的声音到底偏了多少帧
|
|
7257
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
7258
|
+
name: 'motion_audio_verify',
|
|
7259
|
+
description: '音画对照验收:按 motion_sync 锚点,从成片匹配源音乐和音效波形、测切镜或动作ROI的实际变化帧,逐项报告音画及音效误差。无证据标 unverified,超容差 fail。报告写入 .out/qa/sync/report.json。',
|
|
7260
|
+
parameters: {
|
|
7261
|
+
path: { type: 'string' }, nodeId: { type: 'string', required: true, description: '视频节点 id' },
|
|
7262
|
+
page: { type: 'number' },
|
|
7263
|
+
|
|
7264
|
+
},
|
|
7265
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
7266
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
7267
|
+
async execute(args, exec) {
|
|
7268
|
+
const { scope, dir } = await motionProjectOf(args, exec)
|
|
7269
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读')
|
|
7270
|
+
const timeline = readTimeline(dir)
|
|
7271
|
+
const jobs = motionJobs.list(dir).filter((job) => job.kind === 'film' && job.status === 'done')
|
|
7272
|
+
const film = jobs.sort((a, b) => String(b.endedAt || b.createdAt).localeCompare(String(a.endedAt || a.createdAt)))[0]
|
|
7273
|
+
if (!film) throw new Error('还没有渲染完成的成片,先 motion_render start')
|
|
7274
|
+
const output = film.output || outputPath(dir, 'film.mp4')
|
|
7275
|
+
if (!existsSync(output)) throw new Error('成片文件不见了:' + output)
|
|
7276
|
+
const report = await verifySync(dir, output, timeline)
|
|
7277
|
+
return { summary: '成片音画对照:' + report.status + '(' + report.rows.length + ' 个锚点)',
|
|
7278
|
+
details: JSON.stringify(report) }
|
|
7279
|
+
},
|
|
7280
|
+
})))
|
|
7281
|
+
|
|
7282
|
+
// motion_voice 旁白:脚本 → 估时长 → 重排镜头 → 合成 → 字幕
|
|
7283
|
+
//
|
|
7284
|
+
// 为什么时长由旁白说了算:旁白是"内容的长度",画面是"内容的形状"。
|
|
7285
|
+
// 先按字速估、合成后用真实时长再重排一次,镜头跟着句子走 —— 反过来做
|
|
7286
|
+
// (先定死镜头再塞旁白)只能靠变速或砍句,两个都难看。
|
|
7287
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
7288
|
+
name: 'motion_voice',
|
|
7289
|
+
description: '旁白(TTS):把 voice.script.md(## shot-N 分节、一句一行)变成 voice 音轨 + 字幕,并让镜头时长跟着句子走。action=list 看现状/音色;plan 只估算与重排(不合成,先看排期是否合理);say 真合成(edge-tts,拿不到就退 SAPI)+ 用真实时长重排;align 已有音频只按真实时长重排。合成结果按台词哈希缓存,改一句只重合成一句。字幕写进 timeline.subtitles,导出 SRT / 剪映字幕轨零改动。',
|
|
7290
|
+
parameters: {
|
|
7291
|
+
path: { type: 'string' }, nodeId: { type: 'string', required: true, description: '视频节点 id' },
|
|
7292
|
+
page: { type: 'number' },
|
|
7293
|
+
action: { type: 'string', required: true, description: 'list | plan | say | align' },
|
|
7294
|
+
script: { type: 'string', description: '脚本正文(以 # 开头)或文件路径;默认 <工程>/voice.script.md' },
|
|
7295
|
+
voice: { type: 'string', description: '音色,默认 zh-CN-XiaoxiaoNeural(list 看全部)' },
|
|
7296
|
+
rate: { type: 'string', description: '语速,默认 +0%(+8% 更紧凑)' },
|
|
7297
|
+
pitch: { type: 'string', description: '音高,默认 +0Hz' },
|
|
7298
|
+
cps: { type: 'number', description: '估算用的字/秒,中文默认 4.8' },
|
|
7299
|
+
fit: { type: 'string', description: 'grow(默认,镜头只增不减)| exact(按旁白收紧)| none(不动时长)' },
|
|
7300
|
+
gainDb: { type: 'number', description: '旁白增益,默认 0(BGM 侧有 duck 让路)' },
|
|
7301
|
+
force: { type: 'boolean', description: '忽略缓存重新合成' },
|
|
7302
|
+
},
|
|
7303
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { summary: { type: 'string', required: true }, details: { type: 'string', required: true } } },
|
|
7304
|
+
render: (_args, value) => [{ type: 'text', text: value.summary + '\n' + value.details }] },
|
|
7305
|
+
async execute(args, exec) {
|
|
7306
|
+
const { scope, dir } = await motionProjectOf(args, exec)
|
|
7307
|
+
const action = String(args.action || 'list')
|
|
7308
|
+
const timeline = readTimeline(dir)
|
|
7309
|
+
const fps = Number(timeline.fps) || 30
|
|
7310
|
+
|
|
7311
|
+
if (action === 'list') {
|
|
7312
|
+
const cues = (Array.isArray(timeline.audio) ? timeline.audio : []).filter((cue) => cue && cue.role === 'voice')
|
|
7313
|
+
const rows = cues.map((cue) => '- ' + (cue.from / fps).toFixed(2) + 's · ' + (cue.durationInFrames / fps).toFixed(2) + 's · ' + cue.file)
|
|
7314
|
+
const subtitles = Array.isArray(timeline.subtitles) ? timeline.subtitles.length : 0
|
|
7315
|
+
const hasEdge = await edgeAvailable()
|
|
7316
|
+
return {
|
|
7317
|
+
summary: '旁白:' + cues.length + ' 条音轨 / ' + subtitles + ' 条字幕 · 合成器 ' + (hasEdge ? 'edge-tts 可用' : 'edge 不可用(会退回 SAPI)'),
|
|
7318
|
+
details: [
|
|
7319
|
+
rows.length ? rows.join('\n') : '(还没有旁白:先写 ' + joinPath(dir, 'voice.script.md') + ',再 motion_voice action=plan)',
|
|
7320
|
+
'',
|
|
7321
|
+
'音色(' + VOICE_DEFAULTS.voice + ' 是默认):',
|
|
7322
|
+
...VOICE_CHOICES.map((item) => '- ' + item.name + ' · ' + item.gender + ' · ' + item.tags + ' — ' + item.note),
|
|
7323
|
+
'',
|
|
7324
|
+
'脚本格式:## shot-1 一节,一句一行;> 开头的行当备注。',
|
|
7325
|
+
].join('\n'),
|
|
7326
|
+
}
|
|
7327
|
+
}
|
|
7328
|
+
|
|
7329
|
+
if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('这条画布是只读的,改不了旁白')
|
|
7330
|
+
|
|
7331
|
+
if (typeof args.script === 'string' && args.script.trim() && !args.script.trim().startsWith('#'))
|
|
7332
|
+
workspacePath(scope, isAbsolute(args.script) ? args.script : joinPath(dir, args.script.trim()))
|
|
7333
|
+
const { markdown, source } = readScript(dir, args.script)
|
|
7334
|
+
const script = parseScript(markdown)
|
|
7335
|
+
if (!script.length) throw new Error('脚本里没解析出句子:需要 ## shot-1 这样的分节 + 一句一行')
|
|
7336
|
+
const options = { voice: args.voice || VOICE_DEFAULTS.voice, rate: args.rate || VOICE_DEFAULTS.rate, pitch: args.pitch || VOICE_DEFAULTS.pitch, cps: Number(args.cps) || VOICE_DEFAULTS.cps, fit: args.fit || 'grow', gainDb: Number.isFinite(Number(args.gainDb)) ? Number(args.gainDb) : VOICE_DEFAULTS.gainDb, force: args.force === true }
|
|
7337
|
+
|
|
7338
|
+
let planned
|
|
7339
|
+
if (action === 'plan') planned = planNarration(timeline, script, options)
|
|
7340
|
+
else if (action === 'say') planned = await sayNarration(dir, timeline, script, options)
|
|
7341
|
+
else if (action === 'align') planned = await alignNarration(dir, timeline, script, options)
|
|
7342
|
+
else throw new Error('action 只能是 list / plan / say / align')
|
|
7343
|
+
|
|
7344
|
+
const next = planned.timeline
|
|
7345
|
+
writeTimeline(dir, next)
|
|
7346
|
+
const checks = checkNarration(next, planned.lines)
|
|
7347
|
+
const before = timeline.durationInFrames / fps
|
|
7348
|
+
const after = next.durationInFrames / fps
|
|
7349
|
+
const grew = next.shots.filter((shot, index) => shot.durationFrames !== timeline.shots[index].durationFrames).length
|
|
7350
|
+
const speech = totalSpeechSeconds(planned.lines, fps)
|
|
7351
|
+
return {
|
|
7352
|
+
summary: '旁白 ' + planned.lines.length + ' 句 / ' + speech.toFixed(1) + 's · 片长 ' + before.toFixed(1) + 's → ' + after.toFixed(1) + 's'
|
|
7353
|
+
+ (grew ? '(' + grew + ' 镜被重排)' : '(时长没变)') + ' · 检查 ' + checks.verdict,
|
|
7354
|
+
details: [
|
|
7355
|
+
'脚本:' + source + ' · 音色 ' + options.voice + ' · 语速 ' + options.rate + ' · 模式 ' + options.fit,
|
|
7356
|
+
action === 'say' ? '已合成 ' + planned.lines.filter((line) => line.measured).length + ' 句(命中的走缓存,不重复调网)' : '',
|
|
7357
|
+
'',
|
|
7358
|
+
describeNarration(planned.lines, next.shots, fps),
|
|
7359
|
+
'',
|
|
7360
|
+
'检查:',
|
|
7361
|
+
...checks.checks.map((check) => '- [' + check.level + '] ' + check.message),
|
|
7362
|
+
].filter(Boolean).join('\n'),
|
|
7363
|
+
}
|
|
7364
|
+
},
|
|
7365
|
+
})))
|
|
7366
|
+
|
|
5741
7367
|
ctx.effect(() => ctx.tools.register({ ...applyTool, execute: async (args, exec) => {
|
|
5742
7368
|
const sessionId = sessionIdOf(exec)
|
|
5743
7369
|
const path = typeof args.path === 'string' && args.path ? args.path : focusedPathFor(sessionId) || DEFAULT_PATH
|
|
5744
7370
|
const target = await resolveTarget(path, sessionId)
|
|
5745
|
-
return documents.run(absoluteHint(target, path), () =>
|
|
7371
|
+
return documents.run(absoluteHint(target, path), async () => {
|
|
7372
|
+
const current = await resolveTarget(path, sessionId)
|
|
7373
|
+
return documents.run(absoluteHint(current, path), () => applyTool.execute(args, exec))
|
|
7374
|
+
})
|
|
5746
7375
|
} }))
|
|
5747
7376
|
|
|
5748
7377
|
// 把"怎么用这张画布"注册成**内嵌技能**(见 SKILL_BODY 的注释):
|