@crievo/omni 0.0.0-stage → 0.1.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/.env.example +41 -0
- package/README.md +2 -2
- package/bin/omni.mjs +14 -0
- package/docs/onboarding/clone-voice.png +0 -0
- package/docs/onboarding/getting-started.png +0 -0
- package/docs/onboarding/new-video.png +0 -0
- package/docs/onboarding/providers.png +0 -0
- package/docs/onboarding/publish.png +0 -0
- package/docs/onboarding/references.png +0 -0
- package/docs/onboarding.md +212 -0
- package/omnivideo.config.example.json +437 -0
- package/package.json +40 -4
- package/reference/captions.md +156 -0
- package/reference/carousel-contract.md +91 -0
- package/reference/cloud.md +96 -0
- package/reference/learnings.md +792 -0
- package/reference/omnichat.md +305 -0
- package/reference/premiere-mcp.md +91 -0
- package/reference/publish.md +305 -0
- package/reference/scenes-contract.md +109 -0
- package/reference/studio.md +666 -0
- package/reference/voicestudio.md +81 -0
- package/reference/writing-for-voice.md +67 -0
- package/requirements.txt +3 -0
- package/studio/api/app.ts +87 -0
- package/studio/api/lib/errors.ts +33 -0
- package/studio/api/lib/files.ts +41 -0
- package/studio/api/lib/hono.ts +9 -0
- package/studio/api/lib/result.ts +14 -0
- package/studio/api/lib/validate.ts +31 -0
- package/studio/api/lib/video.ts +10 -0
- package/studio/api/middleware/json-charset.ts +11 -0
- package/studio/api/middleware/local-only.ts +32 -0
- package/studio/api/middleware/no-timeout.ts +10 -0
- package/studio/api/routes/admin.ts +18 -0
- package/studio/api/routes/agent-cascade.ts +100 -0
- package/studio/api/routes/agent-history.ts +23 -0
- package/studio/api/routes/agent.ts +162 -0
- package/studio/api/routes/analytics.ts +31 -0
- package/studio/api/routes/approval.ts +60 -0
- package/studio/api/routes/archive.ts +12 -0
- package/studio/api/routes/captions.ts +28 -0
- package/studio/api/routes/carousel.ts +38 -0
- package/studio/api/routes/clone.ts +27 -0
- package/studio/api/routes/create.ts +19 -0
- package/studio/api/routes/events.ts +11 -0
- package/studio/api/routes/export.ts +14 -0
- package/studio/api/routes/media.ts +30 -0
- package/studio/api/routes/omnichat.ts +113 -0
- package/studio/api/routes/onboarding.ts +14 -0
- package/studio/api/routes/publish.ts +40 -0
- package/studio/api/routes/queue.ts +14 -0
- package/studio/api/routes/references.ts +40 -0
- package/studio/api/routes/runner.ts +17 -0
- package/studio/api/routes/schedule.ts +47 -0
- package/studio/api/routes/services.ts +11 -0
- package/studio/api/routes/sessions.ts +27 -0
- package/studio/api/routes/settings.ts +102 -0
- package/studio/api/routes/sound.ts +45 -0
- package/studio/api/routes/thumbnail.ts +18 -0
- package/studio/api/routes/usage.ts +13 -0
- package/studio/api/routes/videos.ts +41 -0
- package/studio/api/routes/voices.ts +71 -0
- package/studio/api/services/admin.ts +113 -0
- package/studio/api/services/agent-cascade.ts +975 -0
- package/studio/api/services/agent-claude.ts +47 -0
- package/studio/api/services/agent-history.ts +103 -0
- package/studio/api/services/agent-persona.ts +77 -0
- package/studio/api/services/agent-tools.ts +2237 -0
- package/studio/api/services/agent-ui-tools.ts +259 -0
- package/studio/api/services/agent-wait.ts +32 -0
- package/studio/api/services/agent.ts +553 -0
- package/studio/api/services/analytics.ts +92 -0
- package/studio/api/services/approval.ts +134 -0
- package/studio/api/services/archive.ts +38 -0
- package/studio/api/services/captions.ts +268 -0
- package/studio/api/services/carousel.ts +206 -0
- package/studio/api/services/classify.ts +196 -0
- package/studio/api/services/clone-phrases.ts +75 -0
- package/studio/api/services/clone.ts +722 -0
- package/studio/api/services/create.ts +421 -0
- package/studio/api/services/events.ts +215 -0
- package/studio/api/services/media.ts +32 -0
- package/studio/api/services/omnichat.ts +624 -0
- package/studio/api/services/onboarding.ts +77 -0
- package/studio/api/services/publish.ts +287 -0
- package/studio/api/services/queue.ts +150 -0
- package/studio/api/services/references.ts +269 -0
- package/studio/api/services/root.ts +60 -0
- package/studio/api/services/services.ts +114 -0
- package/studio/api/services/sessions.ts +1007 -0
- package/studio/api/services/settings.ts +983 -0
- package/studio/api/services/sound.ts +311 -0
- package/studio/api/services/thumbnail.ts +133 -0
- package/studio/api/services/usage.ts +27 -0
- package/studio/api/services/videos.ts +418 -0
- package/studio/api/services/voice-pause.ts +98 -0
- package/studio/api/services/voices.ts +324 -0
- package/studio/runner/runner.ts +244 -0
- package/studio/shared/omni-actions.ts +68 -0
- package/studio/shared/omni-admin.ts +52 -0
- package/studio/shared/omni-farewell.ts +48 -0
- package/studio/shared/omni-hotword.ts +20 -0
- package/studio/shared/omni-screens.ts +228 -0
- package/studio/shared/schemas/admin.ts +15 -0
- package/studio/shared/schemas/agent-cascade.ts +58 -0
- package/studio/shared/schemas/agent-history.ts +73 -0
- package/studio/shared/schemas/agent.ts +50 -0
- package/studio/shared/schemas/analytics.ts +6 -0
- package/studio/shared/schemas/approval.ts +22 -0
- package/studio/shared/schemas/captions.ts +19 -0
- package/studio/shared/schemas/carousel.ts +26 -0
- package/studio/shared/schemas/clone.ts +40 -0
- package/studio/shared/schemas/common.ts +16 -0
- package/studio/shared/schemas/omnichat.ts +54 -0
- package/studio/shared/schemas/onboarding.ts +5 -0
- package/studio/shared/schemas/publish.ts +45 -0
- package/studio/shared/schemas/references.ts +20 -0
- package/studio/shared/schemas/settings.ts +82 -0
- package/studio/shared/schemas/sound.ts +22 -0
- package/studio/shared/schemas/videos.ts +38 -0
- package/studio/shared/schemas/voices.ts +23 -0
- package/studio/shared/types/api.ts +1290 -0
- package/studio/studio.ts +148 -0
- package/studio/tsconfig.json +15 -0
- package/studio/web/assets/logos/cartesia.svg +4 -0
- package/studio/web/assets/logos/elevenlabs.svg +12 -0
- package/studio/web/assets/logos/fish.svg +24 -0
- package/studio/web/assets/logos/omni-black.png +0 -0
- package/studio/web/assets/logos/omni-white.png +0 -0
- package/studio/web/assets/logos/voicestudio.svg +21 -0
- package/studio/web/components/AgentMark.tsx +7 -0
- package/studio/web/components/AgentWalking.tsx +11 -0
- package/studio/web/components/ApiKeyField.tsx +104 -0
- package/studio/web/components/AppShell.tsx +64 -0
- package/studio/web/components/ApprovalNotice.tsx +65 -0
- package/studio/web/components/AvatarGroupIcon.tsx +22 -0
- package/studio/web/components/AvatarNameField.tsx +86 -0
- package/studio/web/components/AvatarPicker.tsx +84 -0
- package/studio/web/components/BrandLogo.tsx +16 -0
- package/studio/web/components/CartesiaPlanSetting.tsx +90 -0
- package/studio/web/components/ClaudeWalking.tsx +32 -0
- package/studio/web/components/CloneVoiceDialog/GuidedScript.tsx +39 -0
- package/studio/web/components/CloneVoiceDialog/LanguageBar.tsx +37 -0
- package/studio/web/components/CloneVoiceDialog/LevelMeter.tsx +31 -0
- package/studio/web/components/CloneVoiceDialog/ProviderChoice.tsx +81 -0
- package/studio/web/components/CloneVoiceDialog/QualityPanel.tsx +62 -0
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +50 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +79 -0
- package/studio/web/components/CloneVoiceDialog/RedoSummary.tsx +54 -0
- package/studio/web/components/CloneVoiceDialog/SaveStep.tsx +91 -0
- package/studio/web/components/CloneVoiceDialog/SourceBar.tsx +30 -0
- package/studio/web/components/CloneVoiceDialog/index.tsx +141 -0
- package/studio/web/components/CloneVoiceDialog/types.ts +28 -0
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +528 -0
- package/studio/web/components/ConnectionSignals.tsx +51 -0
- package/studio/web/components/EditVoiceDialog/EditVoiceFooter.tsx +72 -0
- package/studio/web/components/EditVoiceDialog/ProviderCards.tsx +81 -0
- package/studio/web/components/EditVoiceDialog/index.tsx +256 -0
- package/studio/web/components/ErrorBanner.tsx +115 -0
- package/studio/web/components/FinishedTask.tsx +41 -0
- package/studio/web/components/FishModelSetting.tsx +69 -0
- package/studio/web/components/FishPriceSetting.tsx +44 -0
- package/studio/web/components/HumanBanner.tsx +64 -0
- package/studio/web/components/Lightbox.tsx +41 -0
- package/studio/web/components/LogStream.tsx +42 -0
- package/studio/web/components/Markdown.tsx +21 -0
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +137 -0
- package/studio/web/components/MissingDmsPanel/CommentCard.tsx +168 -0
- package/studio/web/components/MissingDmsPanel/CommentFeed.tsx +45 -0
- package/studio/web/components/MissingDmsPanel/DmBubble.tsx +38 -0
- package/studio/web/components/MissingDmsPanel/ReplyComposer.tsx +63 -0
- package/studio/web/components/MissingDmsPanel/index.tsx +98 -0
- package/studio/web/components/NotFound.tsx +48 -0
- package/studio/web/components/Omni/OmniAvatarPicker.tsx +92 -0
- package/studio/web/components/Omni/OmniCards.tsx +202 -0
- package/studio/web/components/Omni/OmniGlow.tsx +183 -0
- package/studio/web/components/Omni/OmniHistory.tsx +119 -0
- package/studio/web/components/Omni/OmniHotword.tsx +40 -0
- package/studio/web/components/Omni/OmniMicSelect.tsx +92 -0
- package/studio/web/components/Omni/OmniVoiceSample.tsx +81 -0
- package/studio/web/components/Omni/OmniWidget.tsx +743 -0
- package/studio/web/components/Omni/PendingActions.tsx +200 -0
- package/studio/web/components/Omni/Transcript.tsx +127 -0
- package/studio/web/components/OmnichatAccountStatus.tsx +87 -0
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +150 -0
- package/studio/web/components/OmnichatRulePanel/RuleDryRun.tsx +15 -0
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +42 -0
- package/studio/web/components/OmnichatRulePanel/RuleSkeleton.tsx +18 -0
- package/studio/web/components/OmnichatRulePanel/RuleStatus.tsx +55 -0
- package/studio/web/components/OmnichatRulePanel/index.tsx +115 -0
- package/studio/web/components/OmnichatRulePanel/rule-steps.ts +67 -0
- package/studio/web/components/OmnichatRulePanel/steps/MessageStep.tsx +91 -0
- package/studio/web/components/OmnichatRulePanel/steps/RepliesStep.tsx +201 -0
- package/studio/web/components/OmnichatRulePanel/steps/TriggerStep.tsx +76 -0
- package/studio/web/components/OmnichatRulePanel/steps/types.ts +11 -0
- package/studio/web/components/Palette.tsx +17 -0
- package/studio/web/components/PlatformCard.tsx +109 -0
- package/studio/web/components/PlatformIcon.tsx +19 -0
- package/studio/web/components/ProviderCard.tsx +80 -0
- package/studio/web/components/ProviderDetail.tsx +367 -0
- package/studio/web/components/ProviderLogo.tsx +20 -0
- package/studio/web/components/QueuePanel.tsx +77 -0
- package/studio/web/components/ReferenceActions.tsx +89 -0
- package/studio/web/components/ReferencePicker.tsx +136 -0
- package/studio/web/components/ResultMessage.tsx +7 -0
- package/studio/web/components/RuleForm.tsx +138 -0
- package/studio/web/components/RuleLog.tsx +151 -0
- package/studio/web/components/RunningTask.tsx +23 -0
- package/studio/web/components/SampleList.tsx +55 -0
- package/studio/web/components/SessionPanel.tsx +352 -0
- package/studio/web/components/SettingRow.tsx +14 -0
- package/studio/web/components/SettingsCategorySkeleton.tsx +25 -0
- package/studio/web/components/SidePanel.tsx +77 -0
- package/studio/web/components/Sidebar.tsx +289 -0
- package/studio/web/components/StageStepper.tsx +87 -0
- package/studio/web/components/StudioOffline.tsx +80 -0
- package/studio/web/components/TaskName.tsx +17 -0
- package/studio/web/components/TopBanners.tsx +30 -0
- package/studio/web/components/VideoOmnichatPanel.tsx +287 -0
- package/studio/web/components/VoiceAvatar.tsx +33 -0
- package/studio/web/components/VoiceCarousel.tsx +196 -0
- package/studio/web/components/VoiceCategory.tsx +9 -0
- package/studio/web/components/VoiceModal.tsx +39 -0
- package/studio/web/components/VoiceProfileCard.tsx +75 -0
- package/studio/web/components/VoiceProfilePicker.tsx +107 -0
- package/studio/web/components/VoiceProfilesSettings.tsx +150 -0
- package/studio/web/components/VoiceProviderCard.tsx +118 -0
- package/studio/web/components/VoiceProvidersCard.tsx +26 -0
- package/studio/web/components/WaitList.tsx +21 -0
- package/studio/web/components/icons.tsx +247 -0
- package/studio/web/components/ui/accordion.tsx +47 -0
- package/studio/web/components/ui/badge.tsx +18 -0
- package/studio/web/components/ui/calendar.tsx +43 -0
- package/studio/web/components/ui/chart.tsx +229 -0
- package/studio/web/components/ui/checkbox.tsx +26 -0
- package/studio/web/components/ui/cx.ts +2 -0
- package/studio/web/components/ui/date-time-picker.tsx +84 -0
- package/studio/web/components/ui/panel.tsx +23 -0
- package/studio/web/components/ui/picker.tsx +58 -0
- package/studio/web/components/ui/popover.tsx +24 -0
- package/studio/web/components/ui/select.tsx +89 -0
- package/studio/web/components/ui/skeleton.tsx +10 -0
- package/studio/web/components/ui/slider.tsx +68 -0
- package/studio/web/components/ui/switch.tsx +13 -0
- package/studio/web/components/ui/tooltip.test.tsx +30 -0
- package/studio/web/components/ui/tooltip.tsx +59 -0
- package/studio/web/hooks/useAccounts.ts +27 -0
- package/studio/web/hooks/useAdmin.ts +12 -0
- package/studio/web/hooks/useAnalytics.ts +21 -0
- package/studio/web/hooks/useAvatarAudioReactive.ts +87 -0
- package/studio/web/hooks/useBrowserConnect.ts +48 -0
- package/studio/web/hooks/useClonePlan.ts +14 -0
- package/studio/web/hooks/useConnected.ts +7 -0
- package/studio/web/hooks/useLiveQuery.ts +29 -0
- package/studio/web/hooks/useMenuCollapsed.ts +32 -0
- package/studio/web/hooks/useMicDevices.ts +53 -0
- package/studio/web/hooks/useMissingDms.ts +211 -0
- package/studio/web/hooks/useMissingSetupSteps.ts +26 -0
- package/studio/web/hooks/useNow.ts +11 -0
- package/studio/web/hooks/useOmniAgent.ts +278 -0
- package/studio/web/hooks/useOmniClaude.ts +124 -0
- package/studio/web/hooks/useOmniDock.ts +142 -0
- package/studio/web/hooks/useOmniGreeting.ts +60 -0
- package/studio/web/hooks/useOmniHotword.ts +128 -0
- package/studio/web/hooks/useOmnichat.ts +26 -0
- package/studio/web/hooks/useOnboarding.ts +27 -0
- package/studio/web/hooks/usePost.ts +23 -0
- package/studio/web/hooks/useQueue.ts +10 -0
- package/studio/web/hooks/useReferences.ts +20 -0
- package/studio/web/hooks/useRuleEditor.ts +101 -0
- package/studio/web/hooks/useRunner.ts +24 -0
- package/studio/web/hooks/useSaveAction.ts +23 -0
- package/studio/web/hooks/useSchedule.ts +11 -0
- package/studio/web/hooks/useServices.ts +10 -0
- package/studio/web/hooks/useSessions.ts +15 -0
- package/studio/web/hooks/useSettings.ts +11 -0
- package/studio/web/hooks/useSettingsSection.ts +30 -0
- package/studio/web/hooks/useSidePanel.ts +23 -0
- package/studio/web/hooks/useSound.ts +30 -0
- package/studio/web/hooks/useUsage.ts +11 -0
- package/studio/web/hooks/useVideoVoice.ts +36 -0
- package/studio/web/hooks/useVideos.ts +21 -0
- package/studio/web/hooks/useVoiceProfilePreview.ts +52 -0
- package/studio/web/hooks/useVoiceProfiles.ts +7 -0
- package/studio/web/hooks/useVoiceRunning.ts +10 -0
- package/studio/web/hooks/useVoiceStudioClosed.ts +8 -0
- package/studio/web/index.html +19 -0
- package/studio/web/lib/agent.ts +7 -0
- package/studio/web/lib/alerts.ts +210 -0
- package/studio/web/lib/analytics-metrics.ts +351 -0
- package/studio/web/lib/api.ts +70 -0
- package/studio/web/lib/audio.ts +39 -0
- package/studio/web/lib/avatars.ts +41 -0
- package/studio/web/lib/calendar.ts +218 -0
- package/studio/web/lib/caption-position.ts +249 -0
- package/studio/web/lib/carousel.ts +24 -0
- package/studio/web/lib/chime.ts +42 -0
- package/studio/web/lib/clone-voice.ts +55 -0
- package/studio/web/lib/export.ts +11 -0
- package/studio/web/lib/files.ts +8 -0
- package/studio/web/lib/format.ts +93 -0
- package/studio/web/lib/media.ts +29 -0
- package/studio/web/lib/mic-device.ts +72 -0
- package/studio/web/lib/missing-dms.ts +70 -0
- package/studio/web/lib/omni-cards.ts +42 -0
- package/studio/web/lib/omni-cascade.ts +1067 -0
- package/studio/web/lib/omni-greeting-audio.ts +24 -0
- package/studio/web/lib/omni-hotword.ts +231 -0
- package/studio/web/lib/omni-panel.ts +67 -0
- package/studio/web/lib/omni-realtime.ts +811 -0
- package/studio/web/lib/omni-ui.ts +402 -0
- package/studio/web/lib/omni-wait.ts +174 -0
- package/studio/web/lib/omnichat-api.ts +44 -0
- package/studio/web/lib/orb.ts +270 -0
- package/studio/web/lib/pcm-recorder.ts +115 -0
- package/studio/web/lib/platforms.ts +17 -0
- package/studio/web/lib/providers.ts +27 -0
- package/studio/web/lib/publish-draft.ts +52 -0
- package/studio/web/lib/publish-settings.ts +81 -0
- package/studio/web/lib/query-client.ts +122 -0
- package/studio/web/lib/references.ts +13 -0
- package/studio/web/lib/services.ts +40 -0
- package/studio/web/lib/sessions.ts +103 -0
- package/studio/web/lib/settings-categories.ts +58 -0
- package/studio/web/lib/sound-audio.ts +33 -0
- package/studio/web/lib/sound-timeline.ts +291 -0
- package/studio/web/lib/sse.ts +86 -0
- package/studio/web/lib/stage-history.ts +141 -0
- package/studio/web/lib/stages.ts +344 -0
- package/studio/web/lib/studio-offline.ts +60 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/lib/video-tabs.ts +118 -0
- package/studio/web/lib/voice-profile-draft.ts +34 -0
- package/studio/web/lib/voice-profiles.ts +3 -0
- package/studio/web/main.tsx +14 -0
- package/studio/web/pages/AdminPage/index.tsx +123 -0
- package/studio/web/pages/AgentPage/components/Measurements.tsx +102 -0
- package/studio/web/pages/AgentPage/index.tsx +28 -0
- package/studio/web/pages/AnalyticsPage/components/About.tsx +52 -0
- package/studio/web/pages/AnalyticsPage/components/ChannelCards.tsx +106 -0
- package/studio/web/pages/AnalyticsPage/components/ChannelView.tsx +137 -0
- package/studio/web/pages/AnalyticsPage/components/CollectionStatus.tsx +46 -0
- package/studio/web/pages/AnalyticsPage/components/Comparison.tsx +68 -0
- package/studio/web/pages/AnalyticsPage/components/Diagnosis.tsx +50 -0
- package/studio/web/pages/AnalyticsPage/components/Highlights.tsx +49 -0
- package/studio/web/pages/AnalyticsPage/components/Hint.tsx +11 -0
- package/studio/web/pages/AnalyticsPage/components/HookVsBody.tsx +100 -0
- package/studio/web/pages/AnalyticsPage/components/HourlyWave.tsx +133 -0
- package/studio/web/pages/AnalyticsPage/components/Insights.tsx +70 -0
- package/studio/web/pages/AnalyticsPage/components/MetricCard.tsx +46 -0
- package/studio/web/pages/AnalyticsPage/components/PlanSection.tsx +40 -0
- package/studio/web/pages/AnalyticsPage/components/ProgressionChart.tsx +155 -0
- package/studio/web/pages/AnalyticsPage/components/ReelBarsChart.tsx +154 -0
- package/studio/web/pages/AnalyticsPage/components/ReelThumb.tsx +19 -0
- package/studio/web/pages/AnalyticsPage/components/ReelView.tsx +290 -0
- package/studio/web/pages/AnalyticsPage/components/ReelsTable.tsx +269 -0
- package/studio/web/pages/AnalyticsPage/components/RequestWithFocus.tsx +38 -0
- package/studio/web/pages/AnalyticsPage/components/SegmentedControl.tsx +35 -0
- package/studio/web/pages/AnalyticsPage/components/VsMedian.tsx +31 -0
- package/studio/web/pages/AnalyticsPage/index.tsx +17 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +174 -0
- package/studio/web/pages/NewVideoPage/components/DeliverablesPicker.tsx +62 -0
- package/studio/web/pages/NewVideoPage/components/FormatPicker.tsx +33 -0
- package/studio/web/pages/NewVideoPage/index.tsx +133 -0
- package/studio/web/pages/NotFoundPage/index.tsx +9 -0
- package/studio/web/pages/OmnichatPage/components/NewFromLink.tsx +59 -0
- package/studio/web/pages/OmnichatPage/components/RuleListItem.tsx +64 -0
- package/studio/web/pages/OmnichatPage/components/RulePreview.tsx +14 -0
- package/studio/web/pages/OmnichatPage/components/RuleView.tsx +88 -0
- package/studio/web/pages/OmnichatPage/index.tsx +153 -0
- package/studio/web/pages/QueuePage/index.tsx +22 -0
- package/studio/web/pages/ReferencePage/components/ReferenceTexts.tsx +131 -0
- package/studio/web/pages/ReferencePage/index.tsx +216 -0
- package/studio/web/pages/ReferencesPage/components/NewReferenceForm.tsx +150 -0
- package/studio/web/pages/ReferencesPage/components/ReferenceCard.tsx +42 -0
- package/studio/web/pages/ReferencesPage/components/ReferenceGridSkeleton.tsx +31 -0
- package/studio/web/pages/ReferencesPage/index.tsx +67 -0
- package/studio/web/pages/SchedulePage/components/CalendarSkeleton.tsx +18 -0
- package/studio/web/pages/SchedulePage/components/DayView.tsx +177 -0
- package/studio/web/pages/SchedulePage/components/HourPicker.tsx +103 -0
- package/studio/web/pages/SchedulePage/components/MonthDayCell.tsx +111 -0
- package/studio/web/pages/SchedulePage/components/MonthView.tsx +47 -0
- package/studio/web/pages/SchedulePage/components/PlatformIcons.tsx +12 -0
- package/studio/web/pages/SchedulePage/components/PostThumb.tsx +15 -0
- package/studio/web/pages/SchedulePage/components/WeekColumn.tsx +75 -0
- package/studio/web/pages/SchedulePage/components/WeekView.tsx +35 -0
- package/studio/web/pages/SchedulePage/index.tsx +285 -0
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +263 -0
- package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +20 -0
- package/studio/web/pages/SettingsPage/components/ModelPicker.tsx +49 -0
- package/studio/web/pages/SettingsPage/components/ModelsSettings.tsx +138 -0
- package/studio/web/pages/SettingsPage/components/PlatformsCategory.tsx +23 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/AccountStatus.tsx +29 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/AutoPublishPanel.tsx +90 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/BrowserConnect.tsx +62 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/CheckField.tsx +19 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/InstagramPanel.tsx +113 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +146 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SaveBar.tsx +33 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +132 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCard.tsx +47 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SettingsCardGroup.tsx +19 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/TextPanel.tsx +55 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/TiktokPanel.tsx +123 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/YoutubePanel.tsx +100 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/badges.tsx +15 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/index.tsx +40 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/types.ts +10 -0
- package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +389 -0
- package/studio/web/pages/SettingsPage/components/VoiceProvidersCategory.tsx +97 -0
- package/studio/web/pages/SettingsPage/index.tsx +85 -0
- package/studio/web/pages/VideoPage/components/AudioPlayer.tsx +113 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/AxisField.tsx +50 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionList.tsx +56 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionListItem.tsx +92 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionMargins.tsx +57 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionStyleFields.tsx +70 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/CaptionTextForm.tsx +87 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/ColorField.tsx +37 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/MarginField.tsx +59 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/NumberField.tsx +57 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/PositionControls.tsx +140 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/captionEditor.ts +215 -0
- package/studio/web/pages/VideoPage/components/CaptionPositionEditor/useCaptionPosition.ts +154 -0
- package/studio/web/pages/VideoPage/components/CarouselChangeRequest.tsx +72 -0
- package/studio/web/pages/VideoPage/components/CarouselCompositionStage.tsx +80 -0
- package/studio/web/pages/VideoPage/components/CarouselCreate.tsx +63 -0
- package/studio/web/pages/VideoPage/components/CarouselMissing.tsx +19 -0
- package/studio/web/pages/VideoPage/components/CarouselPublishStage.tsx +173 -0
- package/studio/web/pages/VideoPage/components/CarouselScriptStage.tsx +82 -0
- package/studio/web/pages/VideoPage/components/CarouselSlideCard.tsx +49 -0
- package/studio/web/pages/VideoPage/components/CarouselViewer.tsx +55 -0
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +53 -0
- package/studio/web/pages/VideoPage/components/CompositionStage.tsx +78 -0
- package/studio/web/pages/VideoPage/components/CoverPicker.tsx +32 -0
- package/studio/web/pages/VideoPage/components/EditorStage.tsx +53 -0
- package/studio/web/pages/VideoPage/components/LineTakes.tsx +160 -0
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +124 -0
- package/studio/web/pages/VideoPage/components/LineVoicePicker.tsx +37 -0
- package/studio/web/pages/VideoPage/components/MediaImage.tsx +25 -0
- package/studio/web/pages/VideoPage/components/MotionScriptStage.tsx +39 -0
- package/studio/web/pages/VideoPage/components/NarrationStage.tsx +70 -0
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +387 -0
- package/studio/web/pages/VideoPage/components/PublishStage.tsx +76 -0
- package/studio/web/pages/VideoPage/components/PublishText.tsx +149 -0
- package/studio/web/pages/VideoPage/components/ScenesApprovalPanel.tsx +278 -0
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +72 -0
- package/studio/web/pages/VideoPage/components/ScriptApprovalPanel.tsx +189 -0
- package/studio/web/pages/VideoPage/components/ScriptStage.tsx +17 -0
- package/studio/web/pages/VideoPage/components/SequencePlayer.tsx +146 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +117 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/AreaSelection.tsx +74 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedPanel.tsx +94 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +45 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionOverlay.tsx +44 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +32 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionsPanel.tsx +22 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/ClipBlock.tsx +111 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/ClipPanel.tsx +76 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/CutsPanel.tsx +44 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EditorTabBar.tsx +73 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +46 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectItem.tsx +89 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectPanel.tsx +232 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectsRow.tsx +21 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/Lane.tsx +39 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/MarkerRow.tsx +40 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/MuteButton.tsx +36 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/NumberInput.tsx +37 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/PlanWarnings.tsx +19 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/PlayerResizeHandle.tsx +50 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/PreviewControls.tsx +51 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SceneRow.tsx +54 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SettingsPanel.tsx +65 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +99 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundPreview.tsx +82 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/SpeedPanel.tsx +128 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineDock.tsx +56 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +26 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineSkeleton.tsx +21 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineToolbar.tsx +160 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineTracks.tsx +121 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/TrackSidebar.tsx +30 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/VoiceRow.tsx +53 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/Waveform.tsx +97 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/clipActions.ts +50 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/effectActions.ts +161 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/index.tsx +161 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/timelineGeometry.ts +45 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/types.ts +43 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useAutosave.ts +107 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useEditorShortcuts.ts +168 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useFitHeight.ts +40 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useFollowPlayhead.ts +21 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useListenMix.ts +43 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayback.ts +231 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/usePlayerWidth.ts +27 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundData.ts +124 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +511 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/CompareVersions.tsx +111 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/VersionCard.tsx +81 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/VersionMedia.tsx +24 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/VersionText.tsx +15 -0
- package/studio/web/pages/VideoPage/components/StageDrawer/index.tsx +109 -0
- package/studio/web/pages/VideoPage/components/StageProcess.tsx +126 -0
- package/studio/web/pages/VideoPage/components/StageResult.tsx +69 -0
- package/studio/web/pages/VideoPage/components/SwapReference.tsx +92 -0
- package/studio/web/pages/VideoPage/components/ThumbnailPanel.tsx +252 -0
- package/studio/web/pages/VideoPage/components/TimingPanel.tsx +89 -0
- package/studio/web/pages/VideoPage/components/VideoPlayer.tsx +18 -0
- package/studio/web/pages/VideoPage/components/VideoSkeleton.tsx +50 -0
- package/studio/web/pages/VideoPage/components/VideoVoiceInfo.tsx +33 -0
- package/studio/web/pages/VideoPage/components/VideoVoicePicker.tsx +165 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +56 -0
- package/studio/web/pages/VideoPage/components/WithLogo.tsx +10 -0
- package/studio/web/pages/VideoPage/index.tsx +295 -0
- package/studio/web/pages/VideosPage/components/PublishedOn.tsx +85 -0
- package/studio/web/pages/VideosPage/components/ServicesPanel.tsx +81 -0
- package/studio/web/pages/VideosPage/components/SessionsPanel.tsx +62 -0
- package/studio/web/pages/VideosPage/components/VideoListSkeleton.tsx +25 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +122 -0
- package/studio/web/pages/VideosPage/index.tsx +134 -0
- package/studio/web/pages/VoicePage/index.tsx +28 -0
- package/studio/web/router.tsx +201 -0
- package/studio/web/styles/base/elements.css +55 -0
- package/studio/web/styles/base/utilities.css +25 -0
- package/studio/web/styles/components/badge.css +33 -0
- package/studio/web/styles/components/claude-running.css +27 -0
- package/studio/web/styles/components/claude-session.css +212 -0
- package/studio/web/styles/components/claude-walking.css +92 -0
- package/studio/web/styles/components/clone-voice-save.css +136 -0
- package/studio/web/styles/components/clone-voice.css +379 -0
- package/studio/web/styles/components/controls.css +70 -0
- package/studio/web/styles/components/data-and-image.css +54 -0
- package/studio/web/styles/components/forms.css +132 -0
- package/studio/web/styles/components/human-banner.css +99 -0
- package/studio/web/styles/components/lightbox.css +50 -0
- package/studio/web/styles/components/markdown.css +68 -0
- package/studio/web/styles/components/modal.css +70 -0
- package/studio/web/styles/components/omni-cards.css +105 -0
- package/studio/web/styles/components/omni.css +859 -0
- package/studio/web/styles/components/omnichat.css +538 -0
- package/studio/web/styles/components/panel.css +115 -0
- package/studio/web/styles/components/platform-card.css +140 -0
- package/studio/web/styles/components/provider-card.css +132 -0
- package/studio/web/styles/components/queue.css +185 -0
- package/studio/web/styles/components/reference-actions.css +133 -0
- package/studio/web/styles/components/reference-picker.css +146 -0
- package/studio/web/styles/components/results-drawer.css +224 -0
- package/studio/web/styles/components/segmented.css +115 -0
- package/studio/web/styles/components/side-panel-tab.css +23 -0
- package/studio/web/styles/components/status-signals.css +66 -0
- package/studio/web/styles/components/stepper.css +208 -0
- package/studio/web/styles/components/table.css +19 -0
- package/studio/web/styles/components/tabs.css +24 -0
- package/studio/web/styles/components/ui/accordion.css +71 -0
- package/studio/web/styles/components/ui/chart.css +162 -0
- package/studio/web/styles/components/ui/checkbox.css +37 -0
- package/studio/web/styles/components/ui/date-picker.css +153 -0
- package/studio/web/styles/components/ui/select.css +110 -0
- package/studio/web/styles/components/ui/skeleton.css +25 -0
- package/studio/web/styles/components/ui/slider.css +81 -0
- package/studio/web/styles/components/ui/switch.css +41 -0
- package/studio/web/styles/components/ui/tooltip.css +24 -0
- package/studio/web/styles/components/voice-carousel.css +255 -0
- package/studio/web/styles/components/voice-editor.css +179 -0
- package/studio/web/styles/components/voice-grid.css +56 -0
- package/studio/web/styles/components/voice-profiles.css +192 -0
- package/studio/web/styles/design-system.md +136 -0
- package/studio/web/styles/index.css +116 -0
- package/studio/web/styles/layout/page.css +41 -0
- package/studio/web/styles/layout/sidebar-groups.css +39 -0
- package/studio/web/styles/layout/sidebar.css +111 -0
- package/studio/web/styles/pages/agent.css +26 -0
- package/studio/web/styles/pages/analytics/dashboard.css +614 -0
- package/studio/web/styles/pages/analytics/insights.css +48 -0
- package/studio/web/styles/pages/getting-started.css +166 -0
- package/studio/web/styles/pages/new-video/form.css +32 -0
- package/studio/web/styles/pages/not-found.css +59 -0
- package/studio/web/styles/pages/references.css +204 -0
- package/studio/web/styles/pages/schedule/calendar.css +541 -0
- package/studio/web/styles/pages/schedule/chip-states.css +60 -0
- package/studio/web/styles/pages/settings/models.css +72 -0
- package/studio/web/styles/pages/settings/shell.css +361 -0
- package/studio/web/styles/pages/settings/usage.css +63 -0
- package/studio/web/styles/pages/studio-offline.css +105 -0
- package/studio/web/styles/pages/video/caption-controls.css +194 -0
- package/studio/web/styles/pages/video/captions-grid.css +70 -0
- package/studio/web/styles/pages/video/captions.css +79 -0
- package/studio/web/styles/pages/video/editor-chat.css +103 -0
- package/studio/web/styles/pages/video/export.css +50 -0
- package/studio/web/styles/pages/video/frames.css +29 -0
- package/studio/web/styles/pages/video/fullscreen-stage.css +400 -0
- package/studio/web/styles/pages/video/instagram-carousel.css +309 -0
- package/studio/web/styles/pages/video/layout.css +285 -0
- package/studio/web/styles/pages/video/narration.css +138 -0
- package/studio/web/styles/pages/video/preview-overview.css +11 -0
- package/studio/web/styles/pages/video/processing-notice.css +34 -0
- package/studio/web/styles/pages/video/publish-when.css +76 -0
- package/studio/web/styles/pages/video/publish.css +97 -0
- package/studio/web/styles/pages/video/result.css +96 -0
- package/studio/web/styles/pages/video/scenes.css +167 -0
- package/studio/web/styles/pages/video/sound-timeline.css +672 -0
- package/studio/web/styles/pages/video/sound-toolbar.css +149 -0
- package/studio/web/styles/pages/video/take-player.css +100 -0
- package/studio/web/styles/pages/video/thumbnail.css +157 -0
- package/studio/web/styles/pages/video/timeline.css +117 -0
- package/studio/web/styles/pages/video/voice-picker.css +14 -0
- package/studio/web/styles/pages/video-list/active-row.css +27 -0
- package/studio/web/styles/pages/video-list/card-link.css +49 -0
- package/studio/web/styles/pages/video-list/list.css +86 -0
- package/studio/web/styles/theme/identity/details.css +204 -0
- package/studio/web/styles/theme/identity/shell.css +249 -0
- package/studio/web/styles/theme/identity/surfaces.css +183 -0
- package/studio/web/styles/theme/identity/titles-and-buttons.css +120 -0
- package/studio/web/styles/theme/no-borders/blocks.css +208 -0
- package/studio/web/styles/theme/no-borders/controls.css +256 -0
- package/studio/web/styles/tokens.css +245 -0
- package/studio/web/types/api.ts +2 -0
- package/studio/web/types/assets.d.ts +9 -0
- package/templates/brief.md +27 -0
- package/templates/captions.html +204 -0
- package/templates/carousel-composition.md +47 -0
- package/templates/carousel-script.md +60 -0
- package/templates/components/voiceavatar/README.md +87 -0
- package/templates/components/voiceavatar/demo.html +50 -0
- package/templates/components/voiceavatar/voiceavatar.js +415 -0
- package/templates/motion.md +80 -0
- package/templates/playbook.md +43 -0
- package/templates/reference.md +123 -0
- package/templates/scene.html +112 -0
- package/templates/scenes.example.json +96 -0
- package/templates/script.md +63 -0
- package/templates/slide.html +71 -0
- package/templates/wireframe.html +91 -0
- package/tools/analysis.mjs +300 -0
- package/tools/approval.mjs +442 -0
- package/tools/assemble.mjs +942 -0
- package/tools/build-motion.mjs +437 -0
- package/tools/captions.mjs +246 -0
- package/tools/carousel.mjs +558 -0
- package/tools/compose.mjs +307 -0
- package/tools/cuts.mjs +74 -0
- package/tools/editor.mjs +419 -0
- package/tools/epidemic-capture.js +54 -0
- package/tools/fanout.mjs +69 -0
- package/tools/history.mjs +70 -0
- package/tools/history.py +61 -0
- package/tools/job-queue.mjs +180 -0
- package/tools/job_queue.py +430 -0
- package/tools/lib/agents.mjs +412 -0
- package/tools/lib/channel.mjs +399 -0
- package/tools/lib/claude-settings.mjs +67 -0
- package/tools/lib/cli.mjs +9 -0
- package/tools/lib/fanout.mjs +234 -0
- package/tools/lib/http.mjs +94 -0
- package/tools/lib/index.js +14 -0
- package/tools/lib/mcp-server.mjs +69 -0
- package/tools/lib/metrics/metrics.mjs +204 -0
- package/tools/lib/narration.mjs +15 -0
- package/tools/lib/oauth-client.mjs +295 -0
- package/tools/lib/omnichat/config.mjs +139 -0
- package/tools/lib/omnichat/errors.mjs +42 -0
- package/tools/lib/omnichat/extract.mjs +82 -0
- package/tools/lib/omnichat/gate.mjs +151 -0
- package/tools/lib/omnichat/manual.mjs +166 -0
- package/tools/lib/omnichat/match.mjs +78 -0
- package/tools/lib/omnichat/pass.mjs +393 -0
- package/tools/lib/omnichat/record.mjs +208 -0
- package/tools/lib/omnichat/text.mjs +315 -0
- package/tools/lib/omnichat/webhook.mjs +166 -0
- package/tools/lib/pairing.mjs +99 -0
- package/tools/lib/paths.mjs +41 -0
- package/tools/lib/platforms/instagram.mjs +469 -0
- package/tools/lib/platforms/tiktok.mjs +343 -0
- package/tools/lib/platforms/youtube.mjs +228 -0
- package/tools/lib/publish/publish.mjs +244 -0
- package/tools/lib/schedule/schedule.mjs +191 -0
- package/tools/lib/service.mjs +134 -0
- package/tools/lib/skill-client.mjs +126 -0
- package/tools/lib/skills-release.mjs +196 -0
- package/tools/lib/testing.mjs +52 -0
- package/tools/lib/up.mjs +187 -0
- package/tools/metrics.mjs +415 -0
- package/tools/models.mjs +134 -0
- package/tools/narrate.mjs +257 -0
- package/tools/omni.mjs +298 -0
- package/tools/omni_paths.py +12 -0
- package/tools/omnichat.mjs +1577 -0
- package/tools/omnivideo.py +1122 -0
- package/tools/package.mjs +130 -0
- package/tools/platforms.mjs +236 -0
- package/tools/premiere.mjs +533 -0
- package/tools/publish.mjs +1496 -0
- package/tools/reference.mjs +1408 -0
- package/tools/render.mjs +426 -0
- package/tools/runner.mjs +156 -0
- package/tools/setup.mjs +466 -0
- package/tools/skills-release.mjs +93 -0
- package/tools/sound.mjs +1323 -0
- package/tools/soundtrack.mjs +217 -0
- package/tools/thumbnail.mjs +271 -0
- package/tools/timing.mjs +108 -0
- package/tools/usage.py +628 -0
- package/tools/voice-envelope.mjs +185 -0
- package/tools/voice.py +448 -0
- package/tools/voice_sample.py +75 -0
- package/tools/wireframe.mjs +230 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic stages of omnivideo. Reads and writes videos/<slug>/scenes.json.
|
|
3
|
+
|
|
4
|
+
omnivideo.py status <video> which stage the video is at and which is next
|
|
5
|
+
omnivideo.py voice <video> generates new or changed lines (Fish Audio, Cartesia
|
|
6
|
+
or VoiceStudio, with fallback: tools/voice.py)
|
|
7
|
+
omnivideo.py timing <video> duration of each scene and where each line starts
|
|
8
|
+
omnivideo.py captions <video> time of each word (measured on the audio, no transcription)
|
|
9
|
+
and captions.srt
|
|
10
|
+
|
|
11
|
+
`voice` and `captions` accept `--scene cNN` to handle a single scene (what the
|
|
12
|
+
preview uses). `voice --regenerate-fallback` generates again, with the video's
|
|
13
|
+
provider, the lines that came out of a fallback. With `--scene`, `captions`
|
|
14
|
+
writes the word timings but not captions.srt, which only exists for the whole video.
|
|
15
|
+
|
|
16
|
+
Another reading of a line that came out bad, and the choice between readings:
|
|
17
|
+
|
|
18
|
+
omnivideo.py voice <video> --regenerate c03f1[,c05f1] generates again, even with the same text,
|
|
19
|
+
only those lines (with the video's provider)
|
|
20
|
+
omnivideo.py voice <video> --regenerate c03f1 --provider fish
|
|
21
|
+
generates that line with this provider (the others
|
|
22
|
+
stay as fallback); the choice holds for that line only
|
|
23
|
+
(`provider_pinned`) and survives a general `voice`
|
|
24
|
+
omnivideo.py voice <video> --use c03f1 <version> restores history/voice/c03f1/<version>/
|
|
25
|
+
|
|
26
|
+
The reading that is replaced always goes to history/voice/<line>/ (the most
|
|
27
|
+
recent `history.versions`): none is lost when swapping.
|
|
28
|
+
|
|
29
|
+
`status --json` gives the same diagnosis as data, for the studio and other
|
|
30
|
+
integrations. `voice` and `captions` wait for their turn in the machine queue
|
|
31
|
+
(tools/job_queue.py).
|
|
32
|
+
|
|
33
|
+
Run with the project's Python (.venv, created by node tools/setup.mjs), which has requests, soundfile and numpy:
|
|
34
|
+
|
|
35
|
+
.venv/bin/python tools/omnivideo.py voice videos/<slug>
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import itertools
|
|
39
|
+
import json
|
|
40
|
+
import math
|
|
41
|
+
import re
|
|
42
|
+
import shutil
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
48
|
+
from datetime import datetime
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
import usage
|
|
52
|
+
import voice
|
|
53
|
+
from history import keep_previous
|
|
54
|
+
from job_queue import turn
|
|
55
|
+
from omni_paths import DATA_ROOT as ROOT
|
|
56
|
+
|
|
57
|
+
CFG = json.loads((ROOT / "omnivideo.config.json").read_text())
|
|
58
|
+
|
|
59
|
+
WEAK = {
|
|
60
|
+
"de",
|
|
61
|
+
"da",
|
|
62
|
+
"do",
|
|
63
|
+
"das",
|
|
64
|
+
"dos",
|
|
65
|
+
"a",
|
|
66
|
+
"o",
|
|
67
|
+
"as",
|
|
68
|
+
"os",
|
|
69
|
+
"e",
|
|
70
|
+
"em",
|
|
71
|
+
"um",
|
|
72
|
+
"uma",
|
|
73
|
+
"que",
|
|
74
|
+
"com",
|
|
75
|
+
"pra",
|
|
76
|
+
"no",
|
|
77
|
+
"na",
|
|
78
|
+
"nos",
|
|
79
|
+
"nas",
|
|
80
|
+
"por",
|
|
81
|
+
"se",
|
|
82
|
+
"seu",
|
|
83
|
+
"sua",
|
|
84
|
+
"ao",
|
|
85
|
+
"à",
|
|
86
|
+
"às",
|
|
87
|
+
"é",
|
|
88
|
+
"ou",
|
|
89
|
+
"mas",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# stale line fields once the audio changes (`asr_similaridade`: legacy, from when captions transcribed)
|
|
93
|
+
STALE_LINE_KEYS = ("start", "end", "words", "asr_similarity", "asr_similaridade")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fail(msg):
|
|
97
|
+
print(f"erro: {msg}", file=sys.stderr)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load(video):
|
|
102
|
+
path = video / "scenes.json"
|
|
103
|
+
if not path.exists():
|
|
104
|
+
fail(f"{path} não existe — a etapa de roteiro de motion ainda não rodou")
|
|
105
|
+
return json.loads(path.read_text())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def narrated(video):
|
|
109
|
+
"""False: "Sem narração e voz" (content.json `narration: false`). The video skips the spoken script, the
|
|
110
|
+
narration, the voice and the captions: from the request to the motion script, the composition, the
|
|
111
|
+
render, the assembly and the editor, with each scene as long as its composition says."""
|
|
112
|
+
try:
|
|
113
|
+
return json.loads((Path(video) / "content.json").read_text()).get("narration") is not False
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def save(video, data):
|
|
119
|
+
tmp = video / "scenes.json.tmp"
|
|
120
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
121
|
+
tmp.replace(video / "scenes.json")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def all_lines(data):
|
|
125
|
+
for scene in data["scenes"]:
|
|
126
|
+
for line in scene.get("lines", []):
|
|
127
|
+
yield scene, line
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def norm(word):
|
|
131
|
+
return re.sub(r"[^\w]", "", word.lower())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def frame_ceil(seconds, fps):
|
|
135
|
+
return math.ceil(round(seconds * fps, 6)) / fps
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def probe_duration(path):
|
|
139
|
+
out = subprocess.run(
|
|
140
|
+
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
return float(out.stdout.strip())
|
|
146
|
+
except ValueError:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- voice -------------------------------------------------------------------
|
|
151
|
+
# The providers (Fish Audio, Cartesia, VoiceStudio) live in tools/voice.py.
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def with_first(chain, provider):
|
|
155
|
+
"""The chain with `provider` in front (the others follow as fallback, in the same order).
|
|
156
|
+
Only a provider this video's voice configured is valid: it is the list of the screen's picker."""
|
|
157
|
+
if not provider:
|
|
158
|
+
return chain
|
|
159
|
+
found = next((p for p in chain if p.id == provider), None)
|
|
160
|
+
if found is None:
|
|
161
|
+
fail(
|
|
162
|
+
f"{voice.NAMES.get(provider, provider)} não está entre os providers desta voz: "
|
|
163
|
+
f"{', '.join(voice.NAMES[p.id] for p in chain)}"
|
|
164
|
+
)
|
|
165
|
+
return [found] + [p for p in chain if p is not found]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def pending_lines(video, data, chain, only, regenerate_fallback=False, regenerate=()):
|
|
169
|
+
"""Lines without audio, with a different text or instruction, or whose requested
|
|
170
|
+
provider changed. A line generated by a fallback is not redone on its own (the
|
|
171
|
+
default being down would make every run generate everything again): only with
|
|
172
|
+
--regenerate-fallback. Those in `regenerate` (--regenerate) come out again even
|
|
173
|
+
when unchanged: another reading of the same text. With --regenerate, only them:
|
|
174
|
+
changing the video's provider does not drag the other lines along (a line's
|
|
175
|
+
button on the screen generates that line, not the whole video)."""
|
|
176
|
+
pref = chain[0].id
|
|
177
|
+
by_id = {p.id: p for p in chain}
|
|
178
|
+
pending = []
|
|
179
|
+
for scene, line in all_lines(data):
|
|
180
|
+
if only and scene["id"] != only:
|
|
181
|
+
continue
|
|
182
|
+
if regenerate and line["id"] not in regenerate:
|
|
183
|
+
continue
|
|
184
|
+
wav = video / "voice" / f"{line['id']}.wav"
|
|
185
|
+
if line["id"] in regenerate or not wav.exists() or not line.get("hash"):
|
|
186
|
+
pending.append(line)
|
|
187
|
+
continue
|
|
188
|
+
# line from before the providers: VoiceStudio, no provider change
|
|
189
|
+
used = line.get("provider", "voicestudio")
|
|
190
|
+
requested = line.get("provider_requested", used) if "provider" in line else pref
|
|
191
|
+
# `provider_pinned`: the line has its own provider (chosen on the screen), which the video's does not change;
|
|
192
|
+
# what the user chose (provider_pinned) is never redone on its own, not even by --regenerate-fallback
|
|
193
|
+
if not line.get("provider_pinned") and (requested != pref or (regenerate_fallback and used != requested)):
|
|
194
|
+
pending.append(line)
|
|
195
|
+
continue
|
|
196
|
+
d = voice.digest(by_id[used], line) if used in by_id else None
|
|
197
|
+
# VoiceStudio closed: the profile cannot be checked; the audio stays
|
|
198
|
+
if d is not None and d != line["hash"]:
|
|
199
|
+
pending.append(line)
|
|
200
|
+
return pending
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def normalize_wav(src, dest):
|
|
204
|
+
"""Fish and Cartesia stream WAV, sometimes with the wrong header size:
|
|
205
|
+
ffmpeg rewrites it whole, as 16-bit PCM."""
|
|
206
|
+
r = subprocess.run(
|
|
207
|
+
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(src), "-c:a", "pcm_s16le", str(dest)],
|
|
208
|
+
capture_output=True,
|
|
209
|
+
text=True,
|
|
210
|
+
)
|
|
211
|
+
if r.returncode != 0:
|
|
212
|
+
raise voice.Failed(f"áudio inválido: {r.stderr.strip()[:200]}", disable=False)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
RATE_LIMIT_TRIES = 4
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def generate_line(chain, disabled, line, tmp, run=None):
|
|
219
|
+
"""Tries the video's provider and then the fallbacks. A provider that fails on
|
|
220
|
+
key, credit or service is dropped for the rest of the run: the following lines
|
|
221
|
+
all come from the same voice. A 429 (too many lines at once, with the voice in
|
|
222
|
+
parallel) is not a failure of the provider: wait and try it again. `run`
|
|
223
|
+
(usage.Run) records what was generated and what failed."""
|
|
224
|
+
for prov in chain:
|
|
225
|
+
if prov.id in disabled:
|
|
226
|
+
continue
|
|
227
|
+
reason = prov.ready()
|
|
228
|
+
if reason:
|
|
229
|
+
print(f"⚠ {voice.NAMES[prov.id]} fora: {reason}")
|
|
230
|
+
if run:
|
|
231
|
+
run.failure(prov.id, line["id"], reason)
|
|
232
|
+
disabled.add(prov.id)
|
|
233
|
+
continue
|
|
234
|
+
for attempt in range(1, RATE_LIMIT_TRIES + 1):
|
|
235
|
+
try:
|
|
236
|
+
audio = prov.generate(line)
|
|
237
|
+
raw = tmp.with_suffix(".raw")
|
|
238
|
+
raw.write_bytes(audio)
|
|
239
|
+
if prov.id == "voicestudio":
|
|
240
|
+
raw.replace(tmp)
|
|
241
|
+
else:
|
|
242
|
+
normalize_wav(raw, tmp)
|
|
243
|
+
raw.unlink(missing_ok=True)
|
|
244
|
+
return prov
|
|
245
|
+
except voice.Failed as exc:
|
|
246
|
+
if exc.rate_limited and attempt < RATE_LIMIT_TRIES:
|
|
247
|
+
wait = 2 * attempt
|
|
248
|
+
print(f"⚠ {line['id']}: {voice.NAMES[prov.id]} pediu calma (429); de novo em {wait}s")
|
|
249
|
+
time.sleep(wait)
|
|
250
|
+
continue
|
|
251
|
+
print(f"⚠ {line['id']}: {exc}" + ("" if prov is chain[-1] else " → tentando o próximo provider"))
|
|
252
|
+
if run:
|
|
253
|
+
run.failure(prov.id, line["id"], exc)
|
|
254
|
+
if exc.disable:
|
|
255
|
+
disabled.add(prov.id)
|
|
256
|
+
break
|
|
257
|
+
fail(f"{line['id']}: nenhum provider gerou a fala ({', '.join(voice.NAMES[p.id] for p in chain)})")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def voice_groups(lines, provider):
|
|
261
|
+
"""The lines split into equal groups, one per worker, generated at the same
|
|
262
|
+
time: `voice.parallel.lines_per_worker` lines each (12 lines at 4 each = 3
|
|
263
|
+
workers of 4), up to `max_workers`; when the cap is hit, the groups grow and
|
|
264
|
+
stay even (sizes differ by at most one). VoiceStudio runs on this machine and
|
|
265
|
+
generates one line at a time: one group."""
|
|
266
|
+
cfg = CFG["voice"].get("parallel") or {}
|
|
267
|
+
per = max(1, int(cfg.get("lines_per_worker", 4)))
|
|
268
|
+
cap = max(1, int(cfg.get("max_workers", 4)))
|
|
269
|
+
workers = 1 if provider == "voicestudio" else min(cap, math.ceil(len(lines) / per))
|
|
270
|
+
workers = max(1, min(workers, len(lines)))
|
|
271
|
+
size, extra = divmod(len(lines), workers)
|
|
272
|
+
groups, i = [], 0
|
|
273
|
+
for w in range(workers):
|
|
274
|
+
n = size + (1 if w < extra else 0)
|
|
275
|
+
groups.append(lines[i : i + n])
|
|
276
|
+
i += n
|
|
277
|
+
return groups
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_voice(video, only=None, regenerate_fallback=False, regenerate=(), provider=None):
|
|
281
|
+
if not narrated(video):
|
|
282
|
+
fail("este vídeo é sem narração e voz (content.json): não há voz a gerar")
|
|
283
|
+
import requests
|
|
284
|
+
import soundfile as sf
|
|
285
|
+
|
|
286
|
+
data = load(video)
|
|
287
|
+
ids = {l["id"] for _, l in all_lines(data)}
|
|
288
|
+
if set(regenerate) - ids:
|
|
289
|
+
fail(f"fala que não existe: {', '.join(sorted(set(regenerate) - ids))}")
|
|
290
|
+
chain = with_first(voice.chain(requests, data), provider)
|
|
291
|
+
print(
|
|
292
|
+
f"voz: {voice.NAMES[chain[0].id]}"
|
|
293
|
+
+ (f" (fallback: {', '.join(voice.NAMES[p.id] for p in chain[1:])})" if chain[1:] else "")
|
|
294
|
+
)
|
|
295
|
+
(video / "voice").mkdir(exist_ok=True)
|
|
296
|
+
if not pending_lines(video, data, chain, only, regenerate_fallback, regenerate):
|
|
297
|
+
print("todas as falas já estão geradas")
|
|
298
|
+
warn_fallback(data)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# generating voice takes the machine (VoiceStudio is local): wait for the turn in
|
|
302
|
+
# the queue. The wait may be long, and scenes.json is re-read on the turn so as
|
|
303
|
+
# not to overwrite what changed meanwhile
|
|
304
|
+
# usage.Run records each line and, on exit (with or without error), reads the platforms' balance
|
|
305
|
+
with turn("voice", video), usage.Run(video.name) as run:
|
|
306
|
+
data = load(video)
|
|
307
|
+
chain = with_first(voice.chain(requests, data), provider)
|
|
308
|
+
pending = pending_lines(video, data, chain, only, regenerate_fallback, regenerate)
|
|
309
|
+
if not pending:
|
|
310
|
+
print("todas as falas já estão geradas")
|
|
311
|
+
else:
|
|
312
|
+
run.before(chain[0].id)
|
|
313
|
+
disabled = set()
|
|
314
|
+
profile_id = (voice.voice_profile(data)[0] or {}).get("id")
|
|
315
|
+
groups = voice_groups(pending, chain[0].id) if pending else []
|
|
316
|
+
if len(groups) > 1:
|
|
317
|
+
print(f"{len(pending)} fala(s) em {len(groups)} grupos ao mesmo tempo: {', '.join(str(len(g)) for g in groups)}")
|
|
318
|
+
# generating runs in parallel; writing (voice/, history/, scenes.json, the usage log) one at a time
|
|
319
|
+
lock = threading.Lock()
|
|
320
|
+
count = itertools.count(1)
|
|
321
|
+
|
|
322
|
+
def one(line):
|
|
323
|
+
wav = video / "voice" / f"{line['id']}.wav"
|
|
324
|
+
tmp = wav.with_name(f".{wav.stem}.new.wav")
|
|
325
|
+
started = time.time()
|
|
326
|
+
prov = generate_line(chain, disabled, line, tmp, run)
|
|
327
|
+
model = getattr(prov, "model", None)
|
|
328
|
+
with lock:
|
|
329
|
+
digest = voice.digest(prov, line)
|
|
330
|
+
# this line's previous voice goes to history/voice/, with the text it
|
|
331
|
+
# said (scenes.json already has the new text)
|
|
332
|
+
sidecar = wav.with_suffix(".json")
|
|
333
|
+
with keep_previous(
|
|
334
|
+
video, "voice", line["id"], [str(wav.relative_to(video)), str(sidecar.relative_to(video))]
|
|
335
|
+
):
|
|
336
|
+
tmp.replace(wav)
|
|
337
|
+
dur = sf.info(str(wav)).duration
|
|
338
|
+
sidecar.write_text(
|
|
339
|
+
json.dumps(
|
|
340
|
+
{
|
|
341
|
+
"text": line["text"],
|
|
342
|
+
"instruct": line.get("instruct"),
|
|
343
|
+
"hash": digest,
|
|
344
|
+
"provider": prov.id,
|
|
345
|
+
"profile": profile_id,
|
|
346
|
+
"dur": round(dur, 3),
|
|
347
|
+
},
|
|
348
|
+
ensure_ascii=False,
|
|
349
|
+
)
|
|
350
|
+
+ "\n"
|
|
351
|
+
)
|
|
352
|
+
line.update(
|
|
353
|
+
wav=str(wav.relative_to(video)),
|
|
354
|
+
dur=round(dur, 3),
|
|
355
|
+
hash=digest,
|
|
356
|
+
provider=prov.id,
|
|
357
|
+
provider_requested=chain[0].id,
|
|
358
|
+
)
|
|
359
|
+
# the voice (profile) that said the line, for the player to show; with no profile registered, none
|
|
360
|
+
if profile_id:
|
|
361
|
+
line["voice_profile"] = profile_id
|
|
362
|
+
else:
|
|
363
|
+
line.pop("voice_profile", None)
|
|
364
|
+
for stale in STALE_LINE_KEYS:
|
|
365
|
+
line.pop(stale, None)
|
|
366
|
+
if line["id"] in regenerate:
|
|
367
|
+
# another reading with a chosen provider: it stays for this line; without a choice, back to the video's
|
|
368
|
+
if provider:
|
|
369
|
+
line["provider_pinned"] = True
|
|
370
|
+
else:
|
|
371
|
+
line.pop("provider_pinned", None)
|
|
372
|
+
save(video, data)
|
|
373
|
+
run.line(prov.id, line, dur, fallback=prov is not chain[0], model=model)
|
|
374
|
+
rate = len(line["text"].split()) / dur if dur else 0
|
|
375
|
+
flag = " ← ritmo estranho, ouvir" if not 2.2 <= rate <= 4.5 else ""
|
|
376
|
+
via = "" if prov is chain[0] else f" · via {voice.NAMES[prov.id]} (fallback)"
|
|
377
|
+
if prov.id == "fish" and not voice.is_free(model):
|
|
378
|
+
via += f" · {model} (pago)"
|
|
379
|
+
print(
|
|
380
|
+
f"[{next(count)}/{len(pending)}] {line['id']}: {dur:.2f}s de áudio em {time.time() - started:.0f}s "
|
|
381
|
+
f"({rate:.1f} palavras/s){via}{flag}",
|
|
382
|
+
flush=True,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def group(lines):
|
|
386
|
+
for line in lines:
|
|
387
|
+
one(line)
|
|
388
|
+
|
|
389
|
+
# a line no provider generated ends the run (fail → SystemExit): the other
|
|
390
|
+
# groups finish the line they are on, and the error comes out here
|
|
391
|
+
with ThreadPoolExecutor(max_workers=max(1, len(groups))) as pool:
|
|
392
|
+
for _ in pool.map(group, groups):
|
|
393
|
+
pass
|
|
394
|
+
warn_fallback(data)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def cmd_use(video, line_id, version):
|
|
398
|
+
"""Restores a version kept in history/voice/<line>/<version>/ as the line's. The
|
|
399
|
+
one that was active goes to history in its place: swapping back is the same
|
|
400
|
+
command. Only a version that says the current text is valid."""
|
|
401
|
+
data = load(video)
|
|
402
|
+
line = next((l for _, l in all_lines(data) if l["id"] == line_id), None)
|
|
403
|
+
if not line:
|
|
404
|
+
fail(f"a fala {line_id} não existe no scenes.json")
|
|
405
|
+
src = video / "history" / "voice" / line_id / version
|
|
406
|
+
old_wav, old_sidecar = src / f"{line_id}.wav", src / f"{line_id}.json"
|
|
407
|
+
if not old_wav.exists():
|
|
408
|
+
fail(f"não há versão {version} da fala {line_id} em history/voice/")
|
|
409
|
+
if not old_sidecar.exists():
|
|
410
|
+
fail(f"a versão {version} não guardou o texto que dizia ({line_id}.json): não dá para conferir")
|
|
411
|
+
meta = json.loads(old_sidecar.read_text())
|
|
412
|
+
if meta.get("text") != line["text"] or meta.get("instruct") != line.get("instruct"):
|
|
413
|
+
fail(f"a versão {version} diz outro texto: “{meta.get('text')}”. A fala agora é “{line['text']}”")
|
|
414
|
+
|
|
415
|
+
wav = video / "voice" / f"{line_id}.wav"
|
|
416
|
+
sidecar = wav.with_suffix(".json")
|
|
417
|
+
# copy without the old mtime: status sees the export and captions as outdated
|
|
418
|
+
with keep_previous(video, "voice", line_id, [str(wav.relative_to(video)), str(sidecar.relative_to(video))]):
|
|
419
|
+
shutil.copyfile(old_wav, wav)
|
|
420
|
+
shutil.copyfile(old_sidecar, sidecar)
|
|
421
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
422
|
+
dur = meta.get("dur") or probe_duration(wav)
|
|
423
|
+
line.update(
|
|
424
|
+
wav=str(wav.relative_to(video)),
|
|
425
|
+
dur=round(dur, 3),
|
|
426
|
+
hash=meta["hash"],
|
|
427
|
+
provider=meta.get("provider", "voicestudio"),
|
|
428
|
+
provider_requested=voice.preferred(data),
|
|
429
|
+
)
|
|
430
|
+
if meta.get("profile"):
|
|
431
|
+
line["voice_profile"] = meta["profile"]
|
|
432
|
+
else:
|
|
433
|
+
line.pop("voice_profile", None)
|
|
434
|
+
# a chosen version from another provider is a choice, not a fallback: no general `voice` undoes it
|
|
435
|
+
if line["provider"] != line["provider_requested"]:
|
|
436
|
+
line["provider_pinned"] = True
|
|
437
|
+
else:
|
|
438
|
+
line.pop("provider_pinned", None)
|
|
439
|
+
for stale in STALE_LINE_KEYS:
|
|
440
|
+
line.pop(stale, None)
|
|
441
|
+
save(video, data)
|
|
442
|
+
print(
|
|
443
|
+
f"{line_id}: agora usa a versão de {version} ({dur:.2f}s, {voice.NAMES.get(line['provider'], line['provider'])}); "
|
|
444
|
+
"a anterior ficou no historico. Refaça tempo, render, legenda e montagem."
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def mixed_voices(data):
|
|
449
|
+
"""Different providers nobody chose (fallback). A line with provider_pinned was
|
|
450
|
+
chosen by the user on the screen and is left out: it is not something to unify."""
|
|
451
|
+
used = {}
|
|
452
|
+
for _, line in all_lines(data):
|
|
453
|
+
if line.get("wav") and not line.get("provider_pinned"):
|
|
454
|
+
used.setdefault(line.get("provider", "voicestudio"), []).append(line["id"])
|
|
455
|
+
return used if len(used) > 1 else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def warn_fallback(data):
|
|
459
|
+
mixed = mixed_voices(data)
|
|
460
|
+
if mixed:
|
|
461
|
+
print(
|
|
462
|
+
"⚠ vozes de providers diferentes neste vídeo: "
|
|
463
|
+
+ "; ".join(f"{voice.NAMES.get(p, p)}: {', '.join(ids)}" for p, ids in mixed.items())
|
|
464
|
+
)
|
|
465
|
+
pref = voice.preferred(data)
|
|
466
|
+
fallback = [
|
|
467
|
+
l["id"]
|
|
468
|
+
for _, l in all_lines(data)
|
|
469
|
+
if l.get("wav") and l.get("provider", pref) != pref and not l.get("provider_pinned")
|
|
470
|
+
]
|
|
471
|
+
if fallback:
|
|
472
|
+
print(
|
|
473
|
+
f"⚠ {len(fallback)} fala(s) saíram de fallback, não do {voice.NAMES[pref]}: {', '.join(fallback)}. "
|
|
474
|
+
f"Com ele de volta: `omnivideo.py voice <video> --regenerate-fallback`"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# --- timing ------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def cmd_timing_silent(video, data):
|
|
482
|
+
"""No voice: a scene lasts as long as its composition says, the larger of `min_duration` and the last
|
|
483
|
+
beat's `t` plus the hold (a beat's `t` is in seconds, there are no words to tie it to)."""
|
|
484
|
+
t = CFG["timing"]
|
|
485
|
+
fps = data["format"]["fps"]
|
|
486
|
+
clock = 0.0
|
|
487
|
+
print(f"{'cena':<6}{'início':>8}{'duração':>9} (sem narração e voz)")
|
|
488
|
+
for scene in data["scenes"]:
|
|
489
|
+
beats = [b["t"] for b in scene.get("beats", []) if isinstance(b.get("t"), (int, float))]
|
|
490
|
+
if not beats and not scene.get("min_duration"):
|
|
491
|
+
fail(f"cena {scene['id']} sem `min_duration` nem beats com `t`: sem voz, é a composição que dá a duração")
|
|
492
|
+
length = max(scene.get("min_duration", 0.0), (max(beats) + scene.get("hold", t["final_hold"])) if beats else 0.0)
|
|
493
|
+
scene["duration"] = round(frame_ceil(length, fps), 4)
|
|
494
|
+
scene["start"] = round(clock, 4)
|
|
495
|
+
clock += scene["duration"]
|
|
496
|
+
print(f"{scene['id']:<6}{scene['start']:>8.2f}{scene['duration']:>9.2f}")
|
|
497
|
+
data["total_duration"] = round(clock, 4)
|
|
498
|
+
save(video, data)
|
|
499
|
+
print(f"total: {clock:.2f}s")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_timing(video, only=None):
|
|
503
|
+
data = load(video)
|
|
504
|
+
if not narrated(video):
|
|
505
|
+
return cmd_timing_silent(video, data)
|
|
506
|
+
t = CFG["timing"]
|
|
507
|
+
fps = data["format"]["fps"]
|
|
508
|
+
missing = [l["id"] for _, l in all_lines(data) if "dur" not in l]
|
|
509
|
+
if missing:
|
|
510
|
+
fail(f"falas sem áudio: {', '.join(missing)} — rode `omnivideo.py voice` antes")
|
|
511
|
+
|
|
512
|
+
clock = 0.0
|
|
513
|
+
print(f"{'cena':<6}{'início':>8}{'duração':>9}{'voz até':>9} falas")
|
|
514
|
+
for scene in data["scenes"]:
|
|
515
|
+
# a scene may have its own lead_in and hold: a sentence that continues into the
|
|
516
|
+
# next scene must not get the full silence of a cut
|
|
517
|
+
cursor = scene.get("lead_in", t["lead_in"])
|
|
518
|
+
for line in scene.get("lines", []):
|
|
519
|
+
line["start"] = round(frame_ceil(cursor, fps), 4)
|
|
520
|
+
line["end"] = round(line["start"] + line["dur"], 4)
|
|
521
|
+
cursor = line["end"] + t["pause_between_lines"]
|
|
522
|
+
voice_end = scene["lines"][-1]["end"] if scene.get("lines") else 0.0
|
|
523
|
+
length = max(voice_end + scene.get("hold", t["final_hold"]), scene.get("min_duration", 0.0))
|
|
524
|
+
scene["duration"] = round(frame_ceil(length, fps), 4)
|
|
525
|
+
scene["start"] = round(clock, 4)
|
|
526
|
+
clock += scene["duration"]
|
|
527
|
+
print(
|
|
528
|
+
f"{scene['id']:<6}{scene['start']:>8.2f}{scene['duration']:>9.2f}{voice_end:>9.2f} "
|
|
529
|
+
f"{len(scene.get('lines', []))}"
|
|
530
|
+
)
|
|
531
|
+
data["total_duration"] = round(clock, 4)
|
|
532
|
+
save(video, data)
|
|
533
|
+
print(f"total: {clock:.2f}s")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --- captions ----------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def word_weight(word):
|
|
540
|
+
"""How long the word takes, relative to the others: its letters (measured against
|
|
541
|
+
Whisper, letters beat syllables). Numbers and acronyms weigh by what is said."""
|
|
542
|
+
w = norm(word)
|
|
543
|
+
if w.isdigit():
|
|
544
|
+
return 3 * len(w)
|
|
545
|
+
if word.strip(".,;:!?…").isupper() and len(w) > 1:
|
|
546
|
+
return 2.5 * len(w)
|
|
547
|
+
return max(1, len(w))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def voice_spans(wav):
|
|
551
|
+
"""Where the voice starts and ends in the file, and the silences inside (≥ 0.12 s)."""
|
|
552
|
+
import numpy as np
|
|
553
|
+
import soundfile as sf
|
|
554
|
+
|
|
555
|
+
audio, sr = sf.read(str(wav), dtype="float32", always_2d=True)
|
|
556
|
+
audio = audio.mean(axis=1)
|
|
557
|
+
step = int(sr * 0.01)
|
|
558
|
+
n = max(1, len(audio) // step)
|
|
559
|
+
rms = np.sqrt(np.mean(audio[: n * step].reshape(n, step) ** 2, axis=1) + 1e-12)
|
|
560
|
+
# start and end with a low threshold (the opening consonant is weak); silence with a higher one
|
|
561
|
+
edge = rms > max(rms.max() * 0.015, np.percentile(rms, 10) * 2)
|
|
562
|
+
voiced = rms > max(rms.max() * 0.04, np.percentile(rms, 10) * 3)
|
|
563
|
+
if not edge.any():
|
|
564
|
+
return 0.0, len(audio) / sr, []
|
|
565
|
+
start, end = int(np.argmax(edge)), int(n - np.argmax(edge[::-1]))
|
|
566
|
+
silences, k = [], start
|
|
567
|
+
while k < end:
|
|
568
|
+
if not voiced[k]:
|
|
569
|
+
j = k
|
|
570
|
+
while j < end and not voiced[j]:
|
|
571
|
+
j += 1
|
|
572
|
+
if j - k >= 12:
|
|
573
|
+
silences.append((k * 0.01, j * 0.01))
|
|
574
|
+
k = j
|
|
575
|
+
else:
|
|
576
|
+
k += 1
|
|
577
|
+
return start * 0.01, end * 0.01, silences
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
PAUSE = {",": 1.5, ";": 1.5, ":": 1.5, ".": 2.5, "?": 2.5, "!": 2.5, "…": 2.5}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def estimate_words(wav, text):
|
|
584
|
+
"""Time of each word without transcribing: the text is known, so all that is
|
|
585
|
+
left is splitting the spoken span of the audio. Each word weighs by its letters,
|
|
586
|
+
and commas and periods weigh a pause. A real silence near punctuation becomes an
|
|
587
|
+
anchor: the word ends where it starts and the next one enters where it ends."""
|
|
588
|
+
words = text.split()
|
|
589
|
+
if not words:
|
|
590
|
+
return []
|
|
591
|
+
start, end, silences = voice_spans(wav)
|
|
592
|
+
weight = [word_weight(w) for w in words]
|
|
593
|
+
pause = [PAUSE.get(w[-1], 0.0) if k < len(words) - 1 else 0.0 for k, w in enumerate(words)]
|
|
594
|
+
total = sum(weight) + sum(pause)
|
|
595
|
+
|
|
596
|
+
# where each pause would fall by the math; the nearest silence (up to 0.4 s), in order, anchors
|
|
597
|
+
anchors, cursor, used = {}, start, 0
|
|
598
|
+
for k in range(len(words)):
|
|
599
|
+
cursor += (end - start) * weight[k] / total
|
|
600
|
+
if pause[k]:
|
|
601
|
+
mid = cursor + (end - start) * pause[k] / total / 2
|
|
602
|
+
near = [(abs((s + e) / 2 - mid), i) for i, (s, e) in enumerate(silences) if i >= used]
|
|
603
|
+
if near and min(near)[0] <= 0.4:
|
|
604
|
+
i = min(near)[1]
|
|
605
|
+
anchors[k] = silences[i]
|
|
606
|
+
used = i + 1
|
|
607
|
+
cursor += (end - start) * pause[k] / total
|
|
608
|
+
|
|
609
|
+
# between anchors, split by weight
|
|
610
|
+
times, a, seg_start = [], 0, start
|
|
611
|
+
for b in sorted(anchors) + [len(words) - 1]:
|
|
612
|
+
seg_end = anchors[b][0] if b in anchors else end
|
|
613
|
+
p = weight[a : b + 1]
|
|
614
|
+
q = pause[a:b] + [0.0]
|
|
615
|
+
total_seg = sum(p) + sum(q)
|
|
616
|
+
t = seg_start
|
|
617
|
+
for k in range(a, b + 1):
|
|
618
|
+
dur = (seg_end - seg_start) * weight[k] / total_seg
|
|
619
|
+
times.append((t, t + dur))
|
|
620
|
+
t += dur + (seg_end - seg_start) * q[k - a] / total_seg
|
|
621
|
+
if b in anchors:
|
|
622
|
+
seg_start = anchors[b][1]
|
|
623
|
+
a = b + 1
|
|
624
|
+
if a >= len(words):
|
|
625
|
+
break
|
|
626
|
+
return [{"w": w, "s": round(s, 3), "e": round(e, 3)} for w, (s, e) in zip(words, times)]
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def phrases(words, max_chars):
|
|
630
|
+
"""Cuts at sentence ends and commas; joins short comma pieces."""
|
|
631
|
+
length = lambda ws: len(" ".join(w["w"] for w in ws))
|
|
632
|
+
parts, cur = [], []
|
|
633
|
+
for w in words:
|
|
634
|
+
cur.append(w)
|
|
635
|
+
if w["w"].endswith((".", "?", "!", "…", ",", ";", ":")):
|
|
636
|
+
parts.append(cur)
|
|
637
|
+
cur = []
|
|
638
|
+
if cur:
|
|
639
|
+
parts.append(cur)
|
|
640
|
+
merged = []
|
|
641
|
+
for part in parts:
|
|
642
|
+
prev = merged[-1] if merged else None
|
|
643
|
+
if (
|
|
644
|
+
prev
|
|
645
|
+
and prev[-1]["w"].endswith(",")
|
|
646
|
+
and (length(prev) < 12 or length(part) < 8)
|
|
647
|
+
and length(prev + part) <= max_chars
|
|
648
|
+
):
|
|
649
|
+
merged[-1] = prev + part
|
|
650
|
+
else:
|
|
651
|
+
merged.append(part)
|
|
652
|
+
return merged
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def balanced_split(words, max_chars):
|
|
656
|
+
length = lambda ws: len(" ".join(w["w"] for w in ws))
|
|
657
|
+
if length(words) <= max_chars:
|
|
658
|
+
return [words]
|
|
659
|
+
n = math.ceil(length(words) / max_chars)
|
|
660
|
+
best = None
|
|
661
|
+
while best is None or best[0] >= 10_000:
|
|
662
|
+
for cuts in itertools.combinations(range(1, len(words)), n - 1):
|
|
663
|
+
parts = [words[a:b] for a, b in zip((0,) + cuts, cuts + (len(words),))]
|
|
664
|
+
target = length(words) / n
|
|
665
|
+
cost = sum((length(p) - target) ** 2 for p in parts)
|
|
666
|
+
cost += sum(10_000 for p in parts if length(p) > max_chars)
|
|
667
|
+
cost += sum(2_000 for p in parts[:-1] if norm(p[-1]["w"]) in WEAK)
|
|
668
|
+
cost += sum(2_000 for p in parts[:-1] if norm(p[-1]["w"]).isdigit())
|
|
669
|
+
cost += sum(3_000 for p in parts if len(p) == 1)
|
|
670
|
+
if best is None or cost < best[0]:
|
|
671
|
+
best = (cost, parts)
|
|
672
|
+
if best[0] >= 10_000 and n < len(words):
|
|
673
|
+
n += 1
|
|
674
|
+
best = None
|
|
675
|
+
else:
|
|
676
|
+
break
|
|
677
|
+
return best[1]
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def srt_time(seconds):
|
|
681
|
+
ms = int(round(seconds * 1000))
|
|
682
|
+
h, ms = divmod(ms, 3_600_000)
|
|
683
|
+
m, ms = divmod(ms, 60_000)
|
|
684
|
+
s, ms = divmod(ms, 1000)
|
|
685
|
+
return f"{h:02}:{m:02}:{s:02},{ms:03}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def caption_text(words):
|
|
689
|
+
text = " ".join(w["w"] for w in words)
|
|
690
|
+
return text[:-1] if text.endswith((",", ".", ";", ":")) else text
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def cmd_captions(video, only=None):
|
|
694
|
+
if not narrated(video):
|
|
695
|
+
fail("este vídeo é sem narração e voz (content.json): não há falas para legendar")
|
|
696
|
+
# what is missing shows up now, not after waiting for the turn
|
|
697
|
+
targets_of(load(video), only)
|
|
698
|
+
# it takes seconds, but goes through the queue: the studio takes the stage's history and log from it
|
|
699
|
+
with turn("captions", video):
|
|
700
|
+
write_captions(video, only)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def targets_of(data, only):
|
|
704
|
+
targets = [(s, l) for s, l in all_lines(data) if not only or s["id"] == only]
|
|
705
|
+
if not targets:
|
|
706
|
+
fail(f"nenhuma fala{' na cena ' + only if only else ''}")
|
|
707
|
+
if any("wav" not in l for _, l in targets):
|
|
708
|
+
fail("há falas sem áudio — rode `omnivideo.py voice` antes")
|
|
709
|
+
if not only and any("start" not in l or "start" not in s for s, l in targets):
|
|
710
|
+
fail("as falas ainda não têm posição — rode `omnivideo.py timing` antes")
|
|
711
|
+
return targets
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def apply_texts(data, cues):
|
|
715
|
+
"""Text edited on the screen (`captions.texts`, see tools/captions.mjs) goes back to
|
|
716
|
+
the caption the line still generates with the same `original` text: same id,
|
|
717
|
+
else the same line, else any one (the screen's id may have fallen on the
|
|
718
|
+
neighbouring line). An edit with no match is dropped, with a warning: the line changed."""
|
|
719
|
+
cap = data.get("captions") or {}
|
|
720
|
+
left = dict(cap.get("texts") or {})
|
|
721
|
+
if not left:
|
|
722
|
+
return
|
|
723
|
+
line_of = lambda i: i.rsplit(".", 1)[0]
|
|
724
|
+
rules = (lambda c, k: c[3] == k, lambda c, k: line_of(c[3]) == line_of(k), lambda c, k: True)
|
|
725
|
+
matched = {}
|
|
726
|
+
for matches in rules:
|
|
727
|
+
for key, ed in list(left.items()):
|
|
728
|
+
cue = next(
|
|
729
|
+
(c for c in cues if c[3] not in matched and c[2] == ed.get("original") and matches(c, key)), None
|
|
730
|
+
)
|
|
731
|
+
if cue:
|
|
732
|
+
cue[2] = ed["text"]
|
|
733
|
+
matched[cue[3]] = ed
|
|
734
|
+
del left[key]
|
|
735
|
+
for key, ed in left.items():
|
|
736
|
+
print(f"aviso: o texto editado de {key} (“{ed.get('text')}”) saiu: a fala não gera mais “{ed.get('original')}”")
|
|
737
|
+
if matched:
|
|
738
|
+
cap["texts"] = matched
|
|
739
|
+
print(f"{len(matched)} legenda(s) com o texto editado na tela")
|
|
740
|
+
else:
|
|
741
|
+
cap.pop("texts", None)
|
|
742
|
+
if not cap:
|
|
743
|
+
data.pop("captions", None)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def write_captions(video, only):
|
|
747
|
+
data = load(video)
|
|
748
|
+
cc = CFG["captions"]
|
|
749
|
+
targets = targets_of(data, only)
|
|
750
|
+
max_chars = cc["max_chars"][data["format"]["aspect"]]
|
|
751
|
+
|
|
752
|
+
cues = []
|
|
753
|
+
for scene, line in targets:
|
|
754
|
+
words = estimate_words(video / line["wav"], line["text"])
|
|
755
|
+
line["words"] = words
|
|
756
|
+
line.pop("asr_similarity", None) # from when captions transcribed
|
|
757
|
+
line.pop("asr_similaridade", None) # legacy name
|
|
758
|
+
if only:
|
|
759
|
+
continue
|
|
760
|
+
base = scene["start"] + line["start"]
|
|
761
|
+
chunks = [c for part in phrases(words, max_chars) for c in balanced_split(part, max_chars)]
|
|
762
|
+
for n, chunk in enumerate(chunks, 1):
|
|
763
|
+
# id `<line>.<n>`, the same as tools/captions.mjs
|
|
764
|
+
cues.append([base + chunk[0]["s"], base + chunk[-1]["e"], caption_text(chunk), f"{line['id']}.{n}"])
|
|
765
|
+
|
|
766
|
+
for k, cue in enumerate(cues):
|
|
767
|
+
nxt = cues[k + 1][0] if k + 1 < len(cues) else None
|
|
768
|
+
if nxt is not None and nxt - cue[1] < 0.35:
|
|
769
|
+
cue[1] = nxt
|
|
770
|
+
cue[1] = max(cue[1], cue[0] + cc["min_duration"])
|
|
771
|
+
if nxt is not None:
|
|
772
|
+
cue[1] = min(cue[1], nxt)
|
|
773
|
+
|
|
774
|
+
if only:
|
|
775
|
+
save(video, data)
|
|
776
|
+
print(f"{only}: tempo de cada palavra gravado em {len(targets)} fala(s); captions.srt não muda")
|
|
777
|
+
else:
|
|
778
|
+
apply_texts(data, cues)
|
|
779
|
+
srt = "\n".join(f"{i}\n{srt_time(a)} --> {srt_time(b)}\n{t}\n" for i, (a, b, t, _) in enumerate(cues, 1))
|
|
780
|
+
with keep_previous(video, "captions", None, ["captions.srt"]):
|
|
781
|
+
(video / "captions.srt").write_text(srt, encoding="utf-8")
|
|
782
|
+
save(video, data)
|
|
783
|
+
longest = max((len(c[2]) for c in cues), default=0)
|
|
784
|
+
print(f"captions.srt: {len(cues)} legendas, maior com {longest} caracteres (limite {max_chars})")
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# --- status ------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def deadline_passed(a):
|
|
791
|
+
return (
|
|
792
|
+
a.get("mode") == "auto"
|
|
793
|
+
and time.time() >= datetime.fromisoformat(a["deadline"].replace("Z", "+00:00")).timestamp()
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def scenes_approval(video, ids):
|
|
798
|
+
"""Approval of the motion script's scenes (tools/approval.mjs). None: the video
|
|
799
|
+
has no round (made before it existed). A passed deadline in auto mode counts
|
|
800
|
+
as approved, as in approval.mjs, which is the writer."""
|
|
801
|
+
path = video / "motion" / "approval.json"
|
|
802
|
+
if not path.exists():
|
|
803
|
+
return None
|
|
804
|
+
a = json.loads(path.read_text())
|
|
805
|
+
scenes = a.get("scenes", {})
|
|
806
|
+
# round closed: a scene created afterwards (the composition split one) does not reopen the stage
|
|
807
|
+
if a.get("completed_at"):
|
|
808
|
+
ids = [i for i in ids if i in scenes]
|
|
809
|
+
passed = deadline_passed(a)
|
|
810
|
+
states = [(scenes.get(i) or {}).get("state", "pending") for i in ids]
|
|
811
|
+
states = ["approved" if s == "pending" and passed else s for s in states]
|
|
812
|
+
change = [i for i, s in zip(ids, states) if s == "change_requested"]
|
|
813
|
+
pending = [i for i, s in zip(ids, states) if s == "pending"]
|
|
814
|
+
if change and not pending:
|
|
815
|
+
return {"ok": False, "status": "change_requested", "detail": f"cenas pedem alteração: {', '.join(change)}"}
|
|
816
|
+
if pending:
|
|
817
|
+
who = "contagem pausada: o usuário está editando" if a.get("mode") != "auto" else "aprovam sozinhas no prazo"
|
|
818
|
+
return {"ok": False, "status": "pending", "detail": f"cenas esperando aprovação: {', '.join(pending)} ({who})"}
|
|
819
|
+
return {"ok": True, "status": "approved", "detail": None}
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def script_approval(video):
|
|
823
|
+
"""Approval of the script (tools/approval.mjs --script, script-approval.json).
|
|
824
|
+
None: the video has no round (made before it existed, or in the chat without one).
|
|
825
|
+
A passed deadline in auto mode counts as approved, as for the scenes."""
|
|
826
|
+
path = video / "script-approval.json"
|
|
827
|
+
if not path.exists():
|
|
828
|
+
return None
|
|
829
|
+
a = json.loads(path.read_text())
|
|
830
|
+
item = a.get("items", {}).get("script") or {}
|
|
831
|
+
state = item.get("state", "pending")
|
|
832
|
+
if state == "pending" and deadline_passed(a):
|
|
833
|
+
state = "approved"
|
|
834
|
+
if state == "change_requested":
|
|
835
|
+
return {"ok": False, "status": "change_requested", "detail": f"pede alteração: «{item.get('comment', '')}»"}
|
|
836
|
+
if state == "pending":
|
|
837
|
+
who = "contagem pausada: o usuário está editando" if a.get("mode") != "auto" else "aprova sozinho no prazo"
|
|
838
|
+
return {"ok": False, "status": "pending", "detail": f"esperando aprovação ({who})"}
|
|
839
|
+
return {"ok": True, "status": "approved", "detail": None}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
# PT labels of `changed` values, for the human detail text
|
|
843
|
+
CHANGED_LABEL = {"captions": "legenda", "scenes": "cenas", "motion": "motion", "voice": "voz"}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def diagnose(video):
|
|
847
|
+
"""What `status` says, as data: the studio and other integrations read this
|
|
848
|
+
instead of redoing the math."""
|
|
849
|
+
stages = []
|
|
850
|
+
|
|
851
|
+
def stage(id_, number, name, ok, detail=None, **extra):
|
|
852
|
+
stages.append({"id": id_, "number": number, "name": name, "ok": bool(ok), "detail": detail, **extra})
|
|
853
|
+
|
|
854
|
+
silent = not narrated(video)
|
|
855
|
+
|
|
856
|
+
def skipped(id_, number, name):
|
|
857
|
+
""""Sem narração e voz": the stage does not exist for this video; it counts as done and says why."""
|
|
858
|
+
stage(id_, number, name, True, "sem narração e voz", skipped=True)
|
|
859
|
+
|
|
860
|
+
has_script = (video / "script.md").exists()
|
|
861
|
+
script_appr = script_approval(video) if has_script else None
|
|
862
|
+
if silent:
|
|
863
|
+
skipped("script", "1", "roteiro")
|
|
864
|
+
else:
|
|
865
|
+
stage(
|
|
866
|
+
"script",
|
|
867
|
+
"1",
|
|
868
|
+
"roteiro",
|
|
869
|
+
has_script and (script_appr is None or script_appr["ok"]),
|
|
870
|
+
script_appr and script_appr["detail"],
|
|
871
|
+
approval=script_appr and script_appr["status"],
|
|
872
|
+
)
|
|
873
|
+
data = json.loads((video / "scenes.json").read_text()) if (video / "scenes.json").exists() else None
|
|
874
|
+
scenes = data["scenes"] if data else []
|
|
875
|
+
written = (video / "motion.md").exists() and data is not None
|
|
876
|
+
approval = scenes_approval(video, [s["id"] for s in scenes]) if written else None
|
|
877
|
+
stage(
|
|
878
|
+
"motion_script",
|
|
879
|
+
"2",
|
|
880
|
+
"roteiro de motion",
|
|
881
|
+
written and (approval is None or approval["ok"]),
|
|
882
|
+
approval and approval["detail"],
|
|
883
|
+
approval=approval and approval["status"],
|
|
884
|
+
)
|
|
885
|
+
lines = [l for s in scenes for l in s.get("lines", [])]
|
|
886
|
+
# narration and voice apply to the whole video: one scene with lines is not the stage done
|
|
887
|
+
is_narrated = bool(scenes) and all(s.get("lines") for s in scenes) and all(l.get("text") for l in lines)
|
|
888
|
+
voiced = is_narrated and all((video / l.get("wav", "-")).exists() for l in lines)
|
|
889
|
+
stage(
|
|
890
|
+
"motion",
|
|
891
|
+
"3",
|
|
892
|
+
"motion (stills e folha de contatos)",
|
|
893
|
+
bool(scenes) and all((video / s.get("html", f"motion/{s['id']}.html")).exists() for s in scenes),
|
|
894
|
+
)
|
|
895
|
+
if silent:
|
|
896
|
+
skipped("narration", "4", "narração")
|
|
897
|
+
else:
|
|
898
|
+
stage("narration", "4", "narração", is_narrated)
|
|
899
|
+
# the user is swapping voices on the screen: the voice is not decided, and nothing after it runs
|
|
900
|
+
pause_file = video / "voice-pause.json"
|
|
901
|
+
voice_pause = json.loads(pause_file.read_text()) if pause_file.exists() else None
|
|
902
|
+
if silent:
|
|
903
|
+
skipped("voice", "5", "voz")
|
|
904
|
+
else:
|
|
905
|
+
stage(
|
|
906
|
+
"voice",
|
|
907
|
+
"5",
|
|
908
|
+
"voz",
|
|
909
|
+
voiced and not voice_pause,
|
|
910
|
+
"em escolha na tela: o usuário está trocando vozes. Pare aqui; ele segue pelo botão "
|
|
911
|
+
'"Seguir com estas vozes" na etapa Voz'
|
|
912
|
+
if voice_pause
|
|
913
|
+
else None,
|
|
914
|
+
pause=voice_pause,
|
|
915
|
+
)
|
|
916
|
+
stage("timing", "5", "tempo", bool(scenes) and all("duration" in s and "start" in s for s in scenes))
|
|
917
|
+
|
|
918
|
+
shifted = []
|
|
919
|
+
|
|
920
|
+
def rendered(s):
|
|
921
|
+
mp4 = video / s.get("mp4", f"motion/{s['id']}.mp4")
|
|
922
|
+
d = probe_duration(mp4) if mp4.exists() else None
|
|
923
|
+
if d is None or abs(d - s.get("duration", -1)) > 1.5 / data["format"]["fps"]:
|
|
924
|
+
return False
|
|
925
|
+
# background on video time: the MP4 is valid only for the start it was rendered with
|
|
926
|
+
info = video / "motion" / f"{s['id']}.render.json"
|
|
927
|
+
if info.exists():
|
|
928
|
+
r = json.loads(info.read_text())
|
|
929
|
+
if r.get("video_time") and abs(r.get("start", -1) - s.get("start", -2)) > 1e-3:
|
|
930
|
+
shifted.append(s["id"])
|
|
931
|
+
return False
|
|
932
|
+
return True
|
|
933
|
+
|
|
934
|
+
ok_render = bool(scenes) and all(["duration" in s and rendered(s) for s in scenes])
|
|
935
|
+
stage(
|
|
936
|
+
"render",
|
|
937
|
+
"6",
|
|
938
|
+
"render final dos motions",
|
|
939
|
+
ok_render,
|
|
940
|
+
f"início mudou em {', '.join(shifted)}: o fundo pularia no corte" if shifted else None,
|
|
941
|
+
stale=shifted,
|
|
942
|
+
)
|
|
943
|
+
if silent:
|
|
944
|
+
skipped("captions", "7", "legenda")
|
|
945
|
+
else:
|
|
946
|
+
stage("captions", "7", "legenda", (video / "captions.srt").exists() and all("words" in l for l in lines))
|
|
947
|
+
# 8: assemble.mjs (ffmpeg) writes export/assembly.json; a video assembled earlier in
|
|
948
|
+
# Premiere (premiere.json with export) also counts
|
|
949
|
+
assembly = video / "export" / "assembly.json"
|
|
950
|
+
aj = json.loads(assembly.read_text()) if assembly.exists() else {}
|
|
951
|
+
pj = json.loads((video / "premiere.json").read_text()) if (video / "premiere.json").exists() else {}
|
|
952
|
+
# what changed after the export, for the screen to say (and the export-again button to know) what
|
|
953
|
+
inputs = {
|
|
954
|
+
"captions": [video / "captions.srt"],
|
|
955
|
+
"scenes": [video / "scenes.json"],
|
|
956
|
+
"motion": [video / s.get("mp4", f"motion/{s['id']}.mp4") for s in scenes],
|
|
957
|
+
"voice": [video / l["wav"] for l in lines if l.get("wav")],
|
|
958
|
+
}
|
|
959
|
+
if aj:
|
|
960
|
+
ready = (video / aj.get("mp4", "")).exists()
|
|
961
|
+
changed = [
|
|
962
|
+
k
|
|
963
|
+
for k, ps in inputs.items()
|
|
964
|
+
if ready and any(p.exists() and p.stat().st_mtime > assembly.stat().st_mtime for p in ps)
|
|
965
|
+
]
|
|
966
|
+
outdated = bool(changed)
|
|
967
|
+
stage(
|
|
968
|
+
"export",
|
|
969
|
+
"8",
|
|
970
|
+
"montagem e exportação",
|
|
971
|
+
ready and not outdated,
|
|
972
|
+
f"desatualizada: {', '.join(CHANGED_LABEL[k] for k in changed)} mudou depois; rode assemble.mjs de novo"
|
|
973
|
+
if outdated
|
|
974
|
+
else None,
|
|
975
|
+
outdated=outdated,
|
|
976
|
+
changed=changed,
|
|
977
|
+
)
|
|
978
|
+
else:
|
|
979
|
+
legacy_export = bool(pj.get("export")) and Path(pj["export"]).exists()
|
|
980
|
+
stage(
|
|
981
|
+
"export", "8", "montagem e exportação", legacy_export, "exportado pelo Premiere" if legacy_export else None
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
warnings = []
|
|
985
|
+
if data and scenes:
|
|
986
|
+
library = Path(CFG["references"]["dir"])
|
|
987
|
+
refs = data.get("references") or []
|
|
988
|
+
if not refs:
|
|
989
|
+
warnings.append("scenes.json sem `references`: o estilo não saiu de um estilo visual")
|
|
990
|
+
for ref in refs:
|
|
991
|
+
if not (library / ref).is_dir():
|
|
992
|
+
warnings.append(f"estilo visual “{ref}” não existe em {library}")
|
|
993
|
+
warnings.extend(hard_lines(lines))
|
|
994
|
+
mixed = mixed_voices(data)
|
|
995
|
+
if mixed:
|
|
996
|
+
warnings.append(
|
|
997
|
+
"vozes de providers diferentes: "
|
|
998
|
+
+ "; ".join(f"{voice.NAMES.get(p, p)} em {len(ids)} fala(s)" for p, ids in mixed.items())
|
|
999
|
+
)
|
|
1000
|
+
nxt = next((s for s in stages if not s["ok"]), None)
|
|
1001
|
+
return {
|
|
1002
|
+
"video": video.name,
|
|
1003
|
+
"dir": str(video),
|
|
1004
|
+
"stages": stages,
|
|
1005
|
+
"next": {"id": nxt["id"], "name": label(nxt)} if nxt else None,
|
|
1006
|
+
"ready": nxt is None,
|
|
1007
|
+
"total_duration": data["total_duration"] if data and "total_duration" in data else None,
|
|
1008
|
+
"scenes": len(scenes),
|
|
1009
|
+
"lines": len(lines),
|
|
1010
|
+
"warnings": warnings,
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def label(stage):
|
|
1015
|
+
return f"{stage['name']} ({stage['detail']})" if stage["detail"] else stage["name"]
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def hard_lines(lines):
|
|
1019
|
+
"""Punctuation the voice reads as an intonation break (reference/writing-for-voice.md)."""
|
|
1020
|
+
warnings = []
|
|
1021
|
+
for l in lines:
|
|
1022
|
+
t = l.get("text") or ""
|
|
1023
|
+
reasons = []
|
|
1024
|
+
if ":" in t:
|
|
1025
|
+
reasons.append("dois-pontos")
|
|
1026
|
+
if ";" in t:
|
|
1027
|
+
reasons.append("ponto e vírgula")
|
|
1028
|
+
if any(c in t for c in "—–()/"):
|
|
1029
|
+
reasons.append("travessão, parênteses ou barra")
|
|
1030
|
+
if "..." in t or "…" in t:
|
|
1031
|
+
reasons.append("reticências")
|
|
1032
|
+
if t.count(",") > 1:
|
|
1033
|
+
reasons.append(f"{t.count(',')} vírgulas")
|
|
1034
|
+
if re.search(r"\d+\.\d", t):
|
|
1035
|
+
reasons.append("número com ponto (versão?)")
|
|
1036
|
+
if reasons:
|
|
1037
|
+
warnings.append(
|
|
1038
|
+
f"fala {l.get('id')} difícil para a voz ({', '.join(reasons)}): reescreva (reference/writing-for-voice.md)"
|
|
1039
|
+
)
|
|
1040
|
+
return warnings
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def cmd_status(video, as_json=False):
|
|
1044
|
+
d = diagnose(video)
|
|
1045
|
+
if as_json:
|
|
1046
|
+
print(json.dumps(d, ensure_ascii=False, indent=2))
|
|
1047
|
+
return
|
|
1048
|
+
for s in d["stages"]:
|
|
1049
|
+
print(f"{'✓' if s['ok'] else '·'} {label(s)}")
|
|
1050
|
+
if d["total_duration"] is not None:
|
|
1051
|
+
print(f"duração total: {d['total_duration']:.2f}s em {d['scenes']} cenas, {d['lines']} falas")
|
|
1052
|
+
for w in d["warnings"]:
|
|
1053
|
+
print(f"⚠ {w}")
|
|
1054
|
+
print(f"próxima etapa: {d['next']['name']}" if d["next"] else "pronto")
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
COMMANDS = {"status": cmd_status, "voice": cmd_voice, "timing": cmd_timing, "captions": cmd_captions}
|
|
1058
|
+
|
|
1059
|
+
if __name__ == "__main__":
|
|
1060
|
+
args = sys.argv[1:]
|
|
1061
|
+
as_json = "--json" in args
|
|
1062
|
+
if as_json:
|
|
1063
|
+
args.remove("--json")
|
|
1064
|
+
regenerate_fallback = "--regenerate-fallback" in args
|
|
1065
|
+
if regenerate_fallback:
|
|
1066
|
+
args.remove("--regenerate-fallback")
|
|
1067
|
+
regenerate = ()
|
|
1068
|
+
if "--regenerate" in args:
|
|
1069
|
+
i = args.index("--regenerate")
|
|
1070
|
+
regenerate = tuple(x for x in (args[i + 1] if i + 1 < len(args) else "").split(",") if x)
|
|
1071
|
+
args = args[:i] + args[i + 2 :]
|
|
1072
|
+
if not regenerate:
|
|
1073
|
+
fail("--regenerate precisa dos ids das falas (ex.: --regenerate c03f1,c05f1)")
|
|
1074
|
+
provider = None
|
|
1075
|
+
if "--provider" in args:
|
|
1076
|
+
i = args.index("--provider")
|
|
1077
|
+
provider = args[i + 1] if i + 1 < len(args) else None
|
|
1078
|
+
args = args[:i] + args[i + 2 :]
|
|
1079
|
+
if not provider:
|
|
1080
|
+
fail("--provider precisa do id do provider (ex.: --provider fish)")
|
|
1081
|
+
if not regenerate:
|
|
1082
|
+
fail("--provider só vale com --regenerate: é o provider daquela fala, não do vídeo")
|
|
1083
|
+
use = None
|
|
1084
|
+
if "--use" in args:
|
|
1085
|
+
i = args.index("--use")
|
|
1086
|
+
use = args[i + 1 : i + 3]
|
|
1087
|
+
args = args[:i] + args[i + 3 :]
|
|
1088
|
+
if len(use) != 2:
|
|
1089
|
+
fail("--use precisa da fala e da versão (ex.: --use c03f1 2026-10-12T14-03-11-482Z)")
|
|
1090
|
+
only = None
|
|
1091
|
+
if "--scene" in args:
|
|
1092
|
+
i = args.index("--scene")
|
|
1093
|
+
only = args[i + 1] if i + 1 < len(args) else None
|
|
1094
|
+
args = args[:i] + args[i + 2 :]
|
|
1095
|
+
if not only:
|
|
1096
|
+
fail("--scene precisa do id da cena (ex.: --scene c01)")
|
|
1097
|
+
if len(args) != 2 or args[0] not in COMMANDS:
|
|
1098
|
+
print(__doc__)
|
|
1099
|
+
sys.exit(2)
|
|
1100
|
+
if only and args[0] not in ("voice", "captions"):
|
|
1101
|
+
fail("--scene só vale para voice e captions")
|
|
1102
|
+
if as_json and args[0] != "status":
|
|
1103
|
+
fail("--json só vale para status")
|
|
1104
|
+
if regenerate_fallback and args[0] != "voice":
|
|
1105
|
+
fail("--regenerate-fallback só vale para voice")
|
|
1106
|
+
if (regenerate or use) and args[0] != "voice":
|
|
1107
|
+
fail("--regenerate e --use só valem para voice")
|
|
1108
|
+
if regenerate and use:
|
|
1109
|
+
fail("--regenerate gera outra leitura; --use escolhe uma guardada: um de cada vez")
|
|
1110
|
+
target = Path(args[1]).resolve()
|
|
1111
|
+
if not target.is_dir():
|
|
1112
|
+
fail(f"{target} não é uma pasta de vídeo")
|
|
1113
|
+
if only and not any(s["id"] == only for s in load(target)["scenes"]):
|
|
1114
|
+
fail(f"a cena {only} não existe no scenes.json")
|
|
1115
|
+
if as_json:
|
|
1116
|
+
cmd_status(target, as_json=True)
|
|
1117
|
+
elif args[0] == "voice" and use:
|
|
1118
|
+
cmd_use(target, *use)
|
|
1119
|
+
elif args[0] == "voice":
|
|
1120
|
+
cmd_voice(target, only, regenerate_fallback, regenerate, provider)
|
|
1121
|
+
else:
|
|
1122
|
+
COMMANDS[args[0]](target, only) if only else COMMANDS[args[0]](target)
|