@gentbajko/slopify 2.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -21
- package/NOTICE +12 -0
- package/README.md +21 -40
- package/SUBTITLES.md +1 -1
- package/dist/adapter-registry.js +3 -0
- package/dist/adapters/alignment/cache.js +4 -1
- package/dist/adapters/alignment/ctc.js +25 -23
- package/dist/adapters/alignment/index.js +37 -6
- package/dist/adapters/alignment/multilingual.js +267 -0
- package/dist/adapters/alignment/protocol.js +11 -0
- package/dist/adapters/alignment/quality.js +4 -3
- package/dist/adapters/alignment/sentences.js +95 -0
- package/dist/adapters/alignment/spec.js +24 -0
- package/dist/adapters/alignment/spell.js +769 -0
- package/dist/adapters/alignment/text.js +56 -9
- package/dist/adapters/alignment/window.js +34 -30
- package/dist/adapters/alignment/worker.js +23 -21
- package/dist/adapters/explain.js +11 -11
- package/dist/adapters/host-cli/index.js +44 -5
- package/dist/adapters/host-cli/transport.js +7 -5
- package/dist/adapters/image/bytes.js +2 -2
- package/dist/adapters/image/codex-output.js +1 -1
- package/dist/adapters/image/codex.js +87 -22
- package/dist/adapters/image/fal.js +13 -5
- package/dist/adapters/image/google.js +11 -8
- package/dist/adapters/image/openai.js +14 -7
- package/dist/adapters/image/reference.js +34 -0
- package/dist/adapters/image/replicate.js +7 -2
- package/dist/adapters/image/video.js +2 -2
- package/dist/adapters/key-probes.js +36 -0
- package/dist/adapters/llm/claude-code.js +168 -32
- package/dist/adapters/llm/cli-login-error.js +1 -1
- package/dist/adapters/llm/codex-limits.js +157 -0
- package/dist/adapters/llm/codex.js +99 -12
- package/dist/adapters/llm/document-workspace.js +2 -2
- package/dist/adapters/llm/gemini-workspace.js +1 -1
- package/dist/adapters/llm/gemini.js +16 -3
- package/dist/adapters/llm/image-workspace.js +58 -0
- package/dist/adapters/llm/openrouter.js +11 -3
- package/dist/adapters/llm/run-cli.js +12 -7
- package/dist/adapters/tts/cartesia.js +10 -0
- package/dist/adapters/tts/elevenlabs.js +31 -9
- package/dist/adapters/tts/inworld-async.js +9 -5
- package/dist/adapters/tts/inworld.js +17 -3
- package/dist/adapters/tts/openai.js +3 -0
- package/dist/adapters/tts/voice-languages.js +80 -0
- package/dist/assets/fonts/noto/NotoSans-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansArabic-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansDevanagari-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansHebrew-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansThai-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/OFL.txt +93 -0
- package/dist/assets/fonts/noto/SOURCE.txt +18 -0
- package/dist/assets/sample/sample-audiobook.tar +0 -0
- package/dist/assets/sample/sample-podcast.tar +0 -0
- package/dist/assets/sample/sample-project.tar +0 -0
- package/dist/catalog/merge.js +132 -0
- package/dist/catalog/registry.js +13 -13
- package/dist/catalog/schema.js +2 -0
- package/dist/catalog/store.js +147 -8
- package/dist/compose.yaml +71 -0
- package/dist/edge/autostart/docker-record.js +32 -0
- package/dist/edge/autostart/docker.js +50 -0
- package/dist/edge/autostart/index.js +78 -0
- package/dist/edge/autostart/launcher.js +176 -0
- package/dist/edge/autostart/model.js +3 -0
- package/dist/edge/autostart/native.js +210 -0
- package/dist/edge/autostart/prompt.js +66 -0
- package/dist/edge/autostart/service.js +81 -0
- package/dist/edge/cli.js +57 -51
- package/dist/edge/docker-install/activation.js +56 -0
- package/dist/edge/docker-install/apply.js +458 -0
- package/dist/edge/docker-install/engine.js +362 -0
- package/dist/edge/docker-install/run.js +154 -0
- package/dist/edge/docker-install/state.js +146 -0
- package/dist/edge/{docker-projects → docker-install}/volume.js +1 -5
- package/dist/edge/docker.js +14 -22
- package/dist/edge/host-cli.js +1 -1
- package/dist/edge/http/actions.js +32 -10
- package/dist/edge/http/app.js +58 -2
- package/dist/edge/http/auditions.js +70 -0
- package/dist/edge/http/autostart.js +37 -0
- package/dist/edge/http/channel-memory.js +92 -0
- package/dist/edge/http/channels.js +128 -0
- package/dist/edge/http/entries.js +22 -3
- package/dist/edge/http/files.js +1 -1
- package/dist/edge/http/folder-location.js +1 -1
- package/dist/edge/http/home.js +39 -0
- package/dist/edge/http/host-cli.js +29 -5
- package/dist/edge/http/narration-peaks.js +76 -0
- package/dist/edge/http/onboarding.js +152 -0
- package/dist/edge/http/open-folder.js +1 -1
- package/dist/edge/http/patch-notes.js +45 -0
- package/dist/edge/http/planning.js +4 -2
- package/dist/edge/http/project-create.js +87 -0
- package/dist/edge/http/projects.js +31 -99
- package/dist/edge/http/prompts.js +40 -3
- package/dist/edge/http/pronunciations.js +23 -4
- package/dist/edge/http/providers.js +86 -0
- package/dist/edge/http/reviews.js +121 -0
- package/dist/edge/http/revision-files.js +1 -1
- package/dist/edge/http/revisions.js +1 -1
- package/dist/edge/http/run-cost.js +25 -0
- package/dist/edge/http/schedules.js +111 -6
- package/dist/edge/http/settings.js +84 -6
- package/dist/edge/http/storage-files.js +50 -0
- package/dist/edge/http/storage.js +29 -2
- package/dist/edge/http/studio.js +207 -0
- package/dist/edge/http/style-preview.js +100 -0
- package/dist/edge/http/subtitles.js +1 -1
- package/dist/edge/http/trash.js +69 -0
- package/dist/edge/http/update.js +24 -3
- package/dist/edge/http/whats-new.js +15 -0
- package/dist/edge/http/youtube-edits.js +83 -0
- package/dist/edge/native-update.js +62 -0
- package/dist/host-cli/open-folder.js +10 -10
- package/dist/host-cli/runtime.js +9 -1
- package/dist/kernel/db/migrations/0025-automatic-reviews.sql +40 -0
- package/dist/kernel/db/migrations/0026-schedule-topic-generation.sql +23 -0
- package/dist/kernel/db/migrations/0027-run-cost.sql +58 -0
- package/dist/kernel/db/migrations/0030-automatic-retries.sql +18 -0
- package/dist/kernel/db/migrations/0031-prompt-history-and-description-edits.sql +33 -0
- package/dist/kernel/db/migrations/0032-channels.sql +65 -0
- package/dist/kernel/db/migrations/0034-script-prompts.sql +23 -0
- package/dist/kernel/db/migrations/0035-narration-aliases.sql +13 -0
- package/dist/kernel/db/migrations/0037-project-uploads.sql +8 -0
- package/dist/kernel/db/migrations/0038-voice-languages.sql +7 -0
- package/dist/kernel/db/migrations/0039-channel-essentials.sql +64 -0
- package/dist/kernel/db/migrations/0040-standalone-usage.sql +35 -0
- package/dist/kernel/paths.js +27 -6
- package/dist/kernel/pipeline.js +4 -1
- package/dist/kernel/ports/host-cli.js +122 -14
- package/dist/kernel/ports/key-probe.js +1 -0
- package/dist/kernel/ports/languages.js +157 -0
- package/dist/kernel/ports/model.js +5 -2
- package/dist/kernel/ports/narration-aliases.js +60 -0
- package/dist/kernel/ports/plan-limits.js +36 -0
- package/dist/kernel/runner/attempt.js +17 -8
- package/dist/kernel/runner/audition.js +36 -0
- package/dist/kernel/runner/graph.js +24 -5
- package/dist/kernel/runner/index.js +35 -5
- package/dist/kernel/runner/meter.js +1 -0
- package/dist/kernel/runner/providers.js +142 -7
- package/dist/kernel/runner/retry-policy.js +43 -0
- package/dist/kernel/runner/standalone.js +134 -0
- package/dist/kernel/runner/work-authority.js +28 -2
- package/dist/main.js +272 -16
- package/dist/patch-notes/0.4.2-to-1.0.md +93 -0
- package/dist/patch-notes/1.0-to-2.0.md +74 -0
- package/dist/patch-notes/2.0-to-3.0.md +257 -0
- package/dist/patch-notes/3.0.0.md +70 -0
- package/dist/patch-notes/index.json +26 -0
- package/dist/slices/admission/model.js +27 -0
- package/dist/slices/admission/repo.js +24 -4
- package/dist/slices/admission/rules.js +79 -1
- package/dist/slices/admission/schema.js +58 -1
- package/dist/slices/admission/short-mode.js +47 -0
- package/dist/slices/admission/start.js +41 -5
- package/dist/slices/article/continuation.js +2 -2
- package/dist/slices/article/provided-entries.js +2 -2
- package/dist/slices/article/run.js +6 -2
- package/dist/slices/article/segments.js +3 -3
- package/dist/slices/backups/folder.js +14 -6
- package/dist/slices/batch/index.js +7 -2
- package/dist/slices/cancel/index.js +1 -1
- package/dist/slices/channels/cast-images.js +110 -0
- package/dist/slices/channels/cast-match.js +32 -0
- package/dist/slices/channels/images.js +19 -0
- package/dist/slices/channels/model.js +5 -0
- package/dist/slices/channels/rebrand.js +100 -0
- package/dist/slices/channels/repo.js +218 -0
- package/dist/slices/channels/runs.js +129 -0
- package/dist/slices/channels/schema.js +129 -0
- package/dist/slices/channels/service.js +180 -0
- package/dist/slices/channels/studio-csv.js +110 -0
- package/dist/slices/channels/videos.js +120 -0
- package/dist/slices/checkpoints/recovery.js +10 -0
- package/dist/slices/control/index.js +2 -1
- package/dist/slices/document/fonts.js +1 -1
- package/dist/slices/document/render.js +1 -1
- package/dist/slices/episodes/related.js +90 -0
- package/dist/slices/episodes/repo.js +60 -0
- package/dist/slices/episodes/service.js +58 -0
- package/dist/slices/episodes/summarize.js +170 -0
- package/dist/slices/estimate/index.js +109 -28
- package/dist/slices/estimate/requests.js +60 -2
- package/dist/slices/fixes/rules.js +73 -0
- package/dist/slices/fonts/coverage.js +82 -0
- package/dist/slices/fonts/sfnt.js +74 -0
- package/dist/slices/images/run.js +8 -5
- package/dist/slices/images/scale.js +84 -0
- package/dist/slices/library/diff.js +122 -0
- package/dist/slices/library/history.js +100 -0
- package/dist/slices/library/model.js +3 -0
- package/dist/slices/library/photorealistic.js +49 -0
- package/dist/slices/library/repo.js +19 -14
- package/dist/slices/library/save.js +62 -12
- package/dist/slices/library/slots.js +7 -1
- package/dist/slices/library/used-by.js +153 -0
- package/dist/slices/loudness/level-pieces.js +41 -0
- package/dist/slices/loudness/loudnorm.js +164 -0
- package/dist/slices/loudness/model.js +153 -0
- package/dist/slices/model-upkeep/model.js +10 -0
- package/dist/slices/model-upkeep/switch.js +217 -0
- package/dist/slices/model-upkeep/usage.js +183 -0
- package/dist/slices/narration/aliases-library.js +31 -0
- package/dist/slices/narration/aliases-schema.js +53 -0
- package/dist/slices/narration/aliases.js +49 -0
- package/dist/slices/narration/blocks.js +352 -0
- package/dist/slices/narration/chunk.js +19 -7
- package/dist/slices/narration/concat.js +2 -2
- package/dist/slices/narration/describe.js +45 -0
- package/dist/slices/narration/pauses-model.js +63 -0
- package/dist/slices/narration/pauses.js +269 -0
- package/dist/slices/narration/peaks-model.js +1 -0
- package/dist/slices/narration/peaks.js +94 -0
- package/dist/slices/narration/preparation.js +21 -3
- package/dist/slices/narration/pronunciation-chunks.js +3 -3
- package/dist/slices/narration/pronunciation.js +72 -25
- package/dist/slices/narration/run.js +6 -6
- package/dist/slices/narration/shared-glossary.js +1 -1
- package/dist/slices/narration/steering.js +15 -4
- package/dist/slices/notifications/notifier.js +21 -3
- package/dist/slices/notifications/rules.js +22 -1
- package/dist/slices/onboarding/copy.js +143 -0
- package/dist/slices/onboarding/first-run.js +46 -0
- package/dist/slices/onboarding/install.js +119 -0
- package/dist/slices/onboarding/model.js +64 -0
- package/dist/slices/onboarding/packs.js +491 -0
- package/dist/slices/onboarding/quick-short.js +129 -0
- package/dist/slices/onboarding/sample.js +132 -0
- package/dist/slices/onboarding/state.js +68 -0
- package/dist/slices/onboarding/template.js +73 -0
- package/dist/slices/patch-notes/library.js +73 -0
- package/dist/slices/patch-notes/seen.js +77 -0
- package/dist/slices/play-drafts/convert.js +125 -6
- package/dist/slices/play-drafts/readiness.js +3 -0
- package/dist/slices/play-drafts/repo.js +2 -0
- package/dist/slices/play-drafts/review-inputs.js +20 -3
- package/dist/slices/play-drafts/schema.js +89 -0
- package/dist/slices/play-drafts/service.js +4 -1
- package/dist/slices/play-drafts/start.js +43 -18
- package/dist/slices/project-templates/from-project.js +50 -4
- package/dist/slices/project-templates/one-off.js +26 -0
- package/dist/slices/project-templates/repo.js +2 -1
- package/dist/slices/project-templates/service.js +15 -4
- package/dist/slices/project-templates/setup.js +18 -2
- package/dist/slices/rebuild/admission-repo.js +1 -1
- package/dist/slices/rebuild/model.js +6 -0
- package/dist/slices/rebuild/preview-details.js +46 -3
- package/dist/slices/rebuild/preview-plan.js +13 -6
- package/dist/slices/rebuild/recipe-audio-parts.js +40 -7
- package/dist/slices/rebuild/recipe-audio.js +66 -12
- package/dist/slices/rebuild/recipe-build.js +8 -4
- package/dist/slices/rebuild/recipe-cast.js +18 -0
- package/dist/slices/rebuild/recipe-checkpoints.js +11 -9
- package/dist/slices/rebuild/recipe-describe.js +93 -0
- package/dist/slices/rebuild/recipe-edit.js +13 -1
- package/dist/slices/rebuild/recipe-exports.js +62 -8
- package/dist/slices/rebuild/recipe-input-schema.js +35 -0
- package/dist/slices/rebuild/recipe-legacy.js +9 -0
- package/dist/slices/rebuild/recipe-loudness.js +45 -0
- package/dist/slices/rebuild/recipe-model.js +25 -6
- package/dist/slices/rebuild/recipe-pauses.js +7 -0
- package/dist/slices/rebuild/recipe-preparation.js +3 -3
- package/dist/slices/rebuild/recipe-provider-choice.js +11 -1
- package/dist/slices/rebuild/recipe-reference.js +15 -1
- package/dist/slices/rebuild/recipe-reviews.js +117 -0
- package/dist/slices/rebuild/recipe-save.js +4 -1
- package/dist/slices/rebuild/recipe-shorts.js +23 -3
- package/dist/slices/rebuild/recipe-text.js +114 -19
- package/dist/slices/rebuild/recipe-validation.js +14 -2
- package/dist/slices/rebuild/recipe-visual.js +96 -33
- package/dist/slices/rebuild/recipe-voices.js +286 -0
- package/dist/slices/rebuild/recipe-work.js +77 -6
- package/dist/slices/rebuild/recipe-youtube.js +2 -0
- package/dist/slices/rebuild/recovery-conflict.js +7 -3
- package/dist/slices/rebuild/recovery-model.js +3 -0
- package/dist/slices/rebuild/recovery-selection.js +28 -2
- package/dist/slices/rebuild/recovery.js +35 -17
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/retry.js +18 -0
- package/dist/slices/rebuild/review-redo.js +110 -0
- package/dist/slices/rebuild/runtime-actions.js +5 -0
- package/dist/slices/rebuild/runtime-admission.js +4 -1
- package/dist/slices/rebuild/runtime-animate.js +4 -4
- package/dist/slices/rebuild/runtime-article.js +5 -5
- package/dist/slices/rebuild/runtime-document.js +4 -4
- package/dist/slices/rebuild/runtime-export-bed.js +43 -0
- package/dist/slices/rebuild/runtime-export-edit.js +70 -11
- package/dist/slices/rebuild/runtime-export-inputs.js +52 -10
- package/dist/slices/rebuild/runtime-export-short.js +85 -0
- package/dist/slices/rebuild/runtime-export.js +84 -19
- package/dist/slices/rebuild/runtime-figure-card.js +87 -0
- package/dist/slices/rebuild/runtime-image.js +21 -2
- package/dist/slices/rebuild/runtime-local.js +148 -24
- package/dist/slices/rebuild/runtime-materialize.js +8 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +2 -1
- package/dist/slices/rebuild/runtime-narration-text.js +7 -7
- package/dist/slices/rebuild/runtime-pictures.js +28 -0
- package/dist/slices/rebuild/runtime-piece-label.js +27 -0
- package/dist/slices/rebuild/runtime-provider.js +126 -18
- package/dist/slices/rebuild/runtime-publication.js +21 -5
- package/dist/slices/rebuild/runtime-review.js +237 -0
- package/dist/slices/rebuild/runtime-run.js +17 -11
- package/dist/slices/rebuild/runtime-shorts.js +44 -17
- package/dist/slices/rebuild/runtime-store.js +39 -10
- package/dist/slices/rebuild/runtime-subtitles.js +72 -19
- package/dist/slices/rebuild/runtime-voices.js +103 -0
- package/dist/slices/rebuild/runtime-youtube.js +7 -6
- package/dist/slices/rebuild/soften.js +49 -0
- package/dist/slices/rebuild/transition-repo.js +9 -2
- package/dist/slices/research/run.js +4 -4
- package/dist/slices/research/synthesis.js +2 -2
- package/dist/slices/reviews/model.js +105 -0
- package/dist/slices/reviews/outcome.js +22 -0
- package/dist/slices/reviews/repo.js +106 -0
- package/dist/slices/reviews/rules.js +63 -0
- package/dist/slices/reviews/verdict.js +71 -0
- package/dist/slices/reviews/view.js +30 -0
- package/dist/slices/revisions/adopt-content.js +5 -2
- package/dist/slices/revisions/adopt.js +3 -1
- package/dist/slices/revisions/downloads.js +7 -1
- package/dist/slices/revisions/image-plan.js +183 -0
- package/dist/slices/revisions/mutation-assets.js +10 -0
- package/dist/slices/revisions/mutation-cues.js +71 -0
- package/dist/slices/revisions/mutation-request.js +2 -1
- package/dist/slices/revisions/mutations.js +97 -3
- package/dist/slices/revisions/schema.js +11 -1
- package/dist/slices/run-cost/limits.js +128 -0
- package/dist/slices/run-cost/meter.js +47 -0
- package/dist/slices/run-cost/panel.js +234 -0
- package/dist/slices/run-cost/pricing.js +92 -0
- package/dist/slices/run-cost/week.js +108 -0
- package/dist/slices/schedules/agenda.js +158 -0
- package/dist/slices/schedules/repo.js +58 -18
- package/dist/slices/schedules/scheduler.js +67 -16
- package/dist/slices/schedules/schema.js +138 -1
- package/dist/slices/schedules/service.js +43 -4
- package/dist/slices/schedules/similar.js +90 -0
- package/dist/slices/schedules/topic-list.js +163 -0
- package/dist/slices/schedules/topics.js +422 -0
- package/dist/slices/settings/first-run.js +102 -0
- package/dist/slices/settings/health.js +190 -0
- package/dist/slices/settings/key-guides.js +128 -0
- package/dist/slices/settings/key-test.js +85 -0
- package/dist/slices/settings/model.js +6 -1
- package/dist/slices/settings/playback.js +15 -0
- package/dist/slices/settings/repo.js +17 -3
- package/dist/slices/settings/voices.js +64 -4
- package/dist/slices/settings/whats-new.js +64 -0
- package/dist/slices/shorts/captions.js +9 -0
- package/dist/slices/shorts/pick.js +3 -3
- package/dist/slices/shorts/prompts.js +1 -1
- package/dist/slices/shorts/render.js +54 -5
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/backup-export.js +48 -9
- package/dist/slices/storage/backup-format.js +38 -1
- package/dist/slices/storage/backup-import.js +191 -19
- package/dist/slices/storage/delete-project.js +7 -4
- package/dist/slices/storage/documents.js +79 -0
- package/dist/slices/storage/files-location.js +531 -0
- package/dist/slices/storage/layout.js +14 -4
- package/dist/slices/storage/model.js +11 -0
- package/dist/slices/storage/portable.js +92 -12
- package/dist/slices/storage/reconcile.js +14 -1
- package/dist/slices/storage/repo.js +4 -1
- package/dist/slices/storage/schema.js +11 -0
- package/dist/slices/storage/trim.js +146 -0
- package/dist/slices/studio/disclosure.js +74 -0
- package/dist/slices/studio/model.js +28 -0
- package/dist/slices/studio/pack.js +209 -0
- package/dist/slices/studio/settings.js +133 -0
- package/dist/slices/style-preview/images.js +61 -0
- package/dist/slices/style-preview/render.js +161 -0
- package/dist/slices/style-preview/schema.js +45 -0
- package/dist/slices/style-preview/service.js +93 -0
- package/dist/slices/style-preview/settings.js +89 -0
- package/dist/slices/subtitles/captions.js +42 -9
- package/dist/slices/subtitles/model.js +22 -0
- package/dist/slices/subtitles/prepare.js +27 -16
- package/dist/slices/subtitles/transcript.js +1 -1
- package/dist/slices/thumbnail/run.js +7 -7
- package/dist/slices/trash/model.js +25 -0
- package/dist/slices/trash/service.js +273 -0
- package/dist/slices/uploads/repo.js +20 -0
- package/dist/slices/video/ambient-bed-schema.js +15 -0
- package/dist/slices/video/ambient-bed.js +95 -0
- package/dist/slices/video/ambient-mix.js +72 -0
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/cards.js +5 -2
- package/dist/slices/video/edit-list.js +23 -2
- package/dist/slices/video/edit-settings.js +13 -3
- package/dist/slices/video/ffmpeg.js +63 -9
- package/dist/slices/video/figure-card.js +492 -0
- package/dist/slices/video/figure-spans.js +51 -0
- package/dist/slices/video/plan.js +125 -13
- package/dist/slices/video/run.js +14 -12
- package/dist/slices/video/slideshow.js +39 -4
- package/dist/slices/voices/attribution.js +69 -0
- package/dist/slices/voices/audio-files.js +82 -0
- package/dist/slices/voices/audition.js +25 -0
- package/dist/slices/voices/cast.js +56 -0
- package/dist/slices/voices/grouping.js +49 -0
- package/dist/slices/voices/join.js +35 -0
- package/dist/slices/voices/languages.js +25 -0
- package/dist/slices/voices/model.js +179 -0
- package/dist/slices/voices/palette.js +26 -0
- package/dist/slices/voices/panel.js +91 -0
- package/dist/slices/voices/portraits.js +55 -0
- package/dist/slices/voices/script.js +136 -0
- package/dist/slices/voices/timing.js +58 -0
- package/dist/slices/youtube/answer.js +1 -1
- package/dist/slices/youtube/chapters.js +126 -0
- package/dist/slices/youtube/edits-repo.js +69 -0
- package/dist/slices/youtube/edits.js +89 -0
- package/dist/slices/youtube/placeholders.js +98 -0
- package/dist/slices/youtube/transcript.js +11 -4
- package/dist/updater/service.js +66 -21
- package/dist/web/assets/index-CUA2sUxU.css +1 -0
- package/dist/web/assets/index-CX590kE-.js +303 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/web/assets/{pdf-BLF2i8c2.js → pdf-BG8n_uc5.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -4
- package/dist/edge/docker-launch.js +0 -81
- package/dist/edge/docker-projects/activation.js +0 -35
- package/dist/edge/docker-projects/claims.js +0 -85
- package/dist/edge/docker-projects/committed.js +0 -40
- package/dist/edge/docker-projects/engine.js +0 -461
- package/dist/edge/docker-projects/install.js +0 -254
- package/dist/edge/docker-projects/prune.js +0 -66
- package/dist/edge/docker-projects/recover.js +0 -100
- package/dist/edge/docker-projects/state.js +0 -225
- package/dist/web/assets/index-CktpxlJd.js +0 -171
- package/dist/web/assets/index-DB-vOnGU.css +0 -1
- package/scripts/docker-run.sh +0 -3
- /package/dist/edge/{docker-projects → docker-install}/tree.js +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { thumbnailCountOf } from "../admission/model.js";
|
|
4
|
+
import { projectById } from "../admission/repo.js";
|
|
5
|
+
import { channelById, projectChannelId } from "../channels/repo.js";
|
|
6
|
+
import { namesPhotorealisticPrompt } from "../library/photorealistic.js";
|
|
7
|
+
import { isClipPath } from "../rebuild/runtime-export-edit.js";
|
|
8
|
+
import { listVoices } from "../settings/repo.js";
|
|
9
|
+
import { realPersonVoiceIds } from "../settings/voices.js";
|
|
10
|
+
import { fullVideoLine } from "../shorts/model.js";
|
|
11
|
+
import { assetOf } from "../storage/asset-name.js";
|
|
12
|
+
import { contentTypeOf, downloadName, slugOf } from "../storage/downloads.js";
|
|
13
|
+
import { outputPath } from "../storage/layout.js";
|
|
14
|
+
import { outputsOf } from "../storage/repo.js";
|
|
15
|
+
import { videoEditOf } from "../video/edit-settings.js";
|
|
16
|
+
import { usesVoices } from "../voices/model.js";
|
|
17
|
+
import { effectiveDescription } from "../youtube/edits-repo.js";
|
|
18
|
+
import { aiDisclosureOf } from "./disclosure.js";
|
|
19
|
+
import { studioAudience, studioTitleMax, } from "./model.js";
|
|
20
|
+
import { readRealFootage, readStudioPlaylist } from "./settings.js";
|
|
21
|
+
// What `shorts.json` holds for each picked clip; only what the pack uses.
|
|
22
|
+
const clipsSchema = z.object({
|
|
23
|
+
shorts: z.array(z.object({
|
|
24
|
+
number: z.number(),
|
|
25
|
+
first: z.number().optional(),
|
|
26
|
+
last: z.number().optional(),
|
|
27
|
+
title: z.string(),
|
|
28
|
+
description: z.string(),
|
|
29
|
+
hashtags: z.array(z.string()),
|
|
30
|
+
})),
|
|
31
|
+
});
|
|
32
|
+
// Builds the pack from the project's current outputs: the video, the YouTube description and
|
|
33
|
+
// tags, the thumbnails and each short. Nothing is generated here; a part that isn't made yet
|
|
34
|
+
// is left empty and named in `missing` with what to do about it.
|
|
35
|
+
export function uploadPack(deps, projectId) {
|
|
36
|
+
const project = projectById(deps.db, projectId);
|
|
37
|
+
if (project === undefined)
|
|
38
|
+
return { ok: false, reason: "unknown-project" };
|
|
39
|
+
const config = project.config;
|
|
40
|
+
const outputs = outputsOf(deps.db, projectId).filter((output) => available(deps, projectId, output));
|
|
41
|
+
const slug = slugOf(project.title);
|
|
42
|
+
const file = (output) => ({
|
|
43
|
+
url: `/files/${projectId}/${assetOf(output)}`,
|
|
44
|
+
asset: assetOf(output),
|
|
45
|
+
filename: downloadName(slug, output),
|
|
46
|
+
contentType: contentTypeOf(output.path),
|
|
47
|
+
bytes: output.bytes,
|
|
48
|
+
});
|
|
49
|
+
const text = (output) => output === undefined
|
|
50
|
+
? undefined
|
|
51
|
+
: readFileSync(outputPath(deps.paths, projectId, output.path), "utf8").trim();
|
|
52
|
+
const playlist = readStudioPlaylist(deps.db);
|
|
53
|
+
const missing = [];
|
|
54
|
+
// The channel's setting, then what the project narrates and shows (`disclosure.ts`).
|
|
55
|
+
const setting = channelById(deps.db, projectChannelId(deps.db, projectId))?.aiDisclosure ?? "auto";
|
|
56
|
+
const realPersonVoices = realPersonVoicesOf(deps, config);
|
|
57
|
+
const uploadedClips = config.sources.images === "provide"
|
|
58
|
+
? outputs.filter((output) => output.role === "image" && isClipPath(output.path)).length
|
|
59
|
+
: 0;
|
|
60
|
+
const realFootage = uploadedClips > 0 && readRealFootage(deps.db, projectId);
|
|
61
|
+
const atmosphere = videoEditOf(config).atmosphere;
|
|
62
|
+
const disclosure = (kind) => aiDisclosureOf({
|
|
63
|
+
setting,
|
|
64
|
+
kind,
|
|
65
|
+
realPersonVoices,
|
|
66
|
+
// A short's pictures are always drawn anew, never the uploaded clips.
|
|
67
|
+
realFootage: kind === "video" && realFootage,
|
|
68
|
+
footageOverlay: atmosphere === "none" ? undefined : atmosphere,
|
|
69
|
+
photorealistic: kind === "short"
|
|
70
|
+
? namesPhotorealisticPrompt(deps.db, config.shorts?.imagePrompt)
|
|
71
|
+
: config.sources.images === "generate" &&
|
|
72
|
+
config.imagePrompts.some((prompt) => namesPhotorealisticPrompt(deps.db, prompt.name)),
|
|
73
|
+
});
|
|
74
|
+
const video = outputs.find((output) => output.role === "video");
|
|
75
|
+
if (video === undefined)
|
|
76
|
+
missing.push("The video isn't made yet. Let the Video stage finish (or use Continue the run on the project page), then open Prepare upload again.");
|
|
77
|
+
const written = text(outputs.find((output) => output.role === "youtube_description"));
|
|
78
|
+
const writtenTags = text(outputs.find((output) => output.role === "youtube_tags"));
|
|
79
|
+
// As the project page shows it: the user's edits, and channel links filled in.
|
|
80
|
+
const edited = written === undefined
|
|
81
|
+
? undefined
|
|
82
|
+
: effectiveDescription(deps.db, projectId, {
|
|
83
|
+
description: written,
|
|
84
|
+
tags: writtenTags ?? "",
|
|
85
|
+
// The last chapter is checked against the video's own length when it is known.
|
|
86
|
+
durationSeconds: video?.durationMs === null || video?.durationMs === undefined
|
|
87
|
+
? undefined
|
|
88
|
+
: video.durationMs / 1000,
|
|
89
|
+
});
|
|
90
|
+
const tagsFile = edited?.tags ?? writtenTags;
|
|
91
|
+
const description = edited?.description;
|
|
92
|
+
if (description === undefined)
|
|
93
|
+
missing.push(config.youtubeDescription === true
|
|
94
|
+
? "The YouTube description isn't written yet. It is written after the video's subtitle timing; wait for the Video stage to finish."
|
|
95
|
+
: "No YouTube description is written for this project. Turn on YouTube description in Edit project → Video to have one written with chapters and tags.");
|
|
96
|
+
const count = thumbnailCountOf(config);
|
|
97
|
+
// The first thumbnail, then the second and third, and never one left from when the project
|
|
98
|
+
// made three and now makes one.
|
|
99
|
+
const thumbnails = outputs
|
|
100
|
+
.filter((output) => output.role === "thumbnail" && (output.meta.index ?? 1) <= count)
|
|
101
|
+
.sort((a, b) => (a.meta.index ?? 1) - (b.meta.index ?? 1));
|
|
102
|
+
if (thumbnails.length === 0)
|
|
103
|
+
missing.push(config.sources.thumbnail === "off"
|
|
104
|
+
? "This project makes no thumbnail, so YouTube will pick a frame. Set Thumbnail in Edit project to make or upload one."
|
|
105
|
+
: "The thumbnail isn't made yet. Let the Thumbnail stage finish, then open Prepare upload again.");
|
|
106
|
+
else if (thumbnails.length < count)
|
|
107
|
+
missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
|
|
108
|
+
if (playlist === null)
|
|
109
|
+
missing.push("No playlist is set, so the playlist step is left to you. Set one in Settings → YouTube Studio.");
|
|
110
|
+
const items = [
|
|
111
|
+
{
|
|
112
|
+
kind: "video",
|
|
113
|
+
video: video === undefined ? null : file(video),
|
|
114
|
+
title: project.title.slice(0, studioTitleMax),
|
|
115
|
+
description: description ?? "",
|
|
116
|
+
tags: tagsOf(tagsFile),
|
|
117
|
+
thumbnails: thumbnails.map(file),
|
|
118
|
+
audience: studioAudience,
|
|
119
|
+
alteredContent: disclosure("video"),
|
|
120
|
+
playlist,
|
|
121
|
+
...(edited?.chapterNotice === undefined ? {} : { chapterNotice: edited.chapterNotice }),
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
const clips = clipsOf(text(outputs.find((output) => output.role === "shorts")));
|
|
125
|
+
const shortVideos = outputs.filter((output) => output.role === "short_video");
|
|
126
|
+
for (const clip of clips) {
|
|
127
|
+
// The clip's own render: the one cut from the same sentences, else the newest for its
|
|
128
|
+
// number (a list written before the sentences were kept).
|
|
129
|
+
const mine = shortVideos.filter((output) => output.meta.short === clip.number);
|
|
130
|
+
const exact = mine.find((output) => clip.first !== undefined &&
|
|
131
|
+
output.meta.sentences?.[0] === clip.first &&
|
|
132
|
+
output.meta.sentences?.[1] === clip.last);
|
|
133
|
+
const render = exact ?? mine.at(-1);
|
|
134
|
+
if (render === undefined)
|
|
135
|
+
missing.push(`Short ${String(clip.number)} isn't rendered yet. Let the Video stage finish, then open Prepare upload again.`);
|
|
136
|
+
items.push({
|
|
137
|
+
kind: "short",
|
|
138
|
+
short: clip.number,
|
|
139
|
+
video: render === undefined ? null : file(render),
|
|
140
|
+
title: clip.title.slice(0, studioTitleMax),
|
|
141
|
+
description: [
|
|
142
|
+
clip.description,
|
|
143
|
+
fullVideoLine(config.shorts?.fullVideoLink),
|
|
144
|
+
"",
|
|
145
|
+
clip.hashtags.join(" "),
|
|
146
|
+
].join("\n"),
|
|
147
|
+
// The hashtags without their #, as search tags.
|
|
148
|
+
tags: clip.hashtags.map((tag) => tag.replace(/^#+/, "").trim()).filter((tag) => tag !== ""),
|
|
149
|
+
// Studio shows a frame of a short; a custom thumbnail isn't offered in its upload dialog.
|
|
150
|
+
thumbnails: [],
|
|
151
|
+
audience: studioAudience,
|
|
152
|
+
alteredContent: disclosure("short"),
|
|
153
|
+
playlist,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
pack: {
|
|
159
|
+
projectId,
|
|
160
|
+
projectTitle: project.title,
|
|
161
|
+
items,
|
|
162
|
+
missing,
|
|
163
|
+
...(uploadedClips > 0 ? { footage: { clips: uploadedClips, real: realFootage } } : {}),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// The saved voices marked "Imitates a real person" that narrate the project: its one narration
|
|
168
|
+
// voice, or every speaker's of a multi-voice run. None when the narration isn't an AI voice.
|
|
169
|
+
function realPersonVoicesOf(deps, config) {
|
|
170
|
+
if (config.sources.audio !== "generate")
|
|
171
|
+
return [];
|
|
172
|
+
const narrating = usesVoices(config)
|
|
173
|
+
? (config.voices?.speakers ?? []).map((speaker) => speaker.voice)
|
|
174
|
+
: config.audio === undefined
|
|
175
|
+
? []
|
|
176
|
+
: [config.audio];
|
|
177
|
+
const flagged = realPersonVoiceIds(deps.db);
|
|
178
|
+
const names = listVoices(deps.db)
|
|
179
|
+
.filter((voice) => flagged.has(voice.id) &&
|
|
180
|
+
narrating.some((one) => one.provider === voice.provider && one.voice === voice.voiceId))
|
|
181
|
+
.map((voice) => voice.name);
|
|
182
|
+
return [...new Set(names)];
|
|
183
|
+
}
|
|
184
|
+
// The item the person chose: the long video, or short `short`.
|
|
185
|
+
export function packItem(pack, short) {
|
|
186
|
+
return pack.items.find((item) => short === undefined ? item.kind === "video" : item.kind === "short" && item.short === short);
|
|
187
|
+
}
|
|
188
|
+
function tagsOf(line) {
|
|
189
|
+
return (line ?? "")
|
|
190
|
+
.split(",")
|
|
191
|
+
.map((tag) => tag.trim())
|
|
192
|
+
.filter((tag) => tag !== "");
|
|
193
|
+
}
|
|
194
|
+
function clipsOf(text) {
|
|
195
|
+
if (text === undefined)
|
|
196
|
+
return [];
|
|
197
|
+
try {
|
|
198
|
+
const parsed = clipsSchema.safeParse(JSON.parse(text));
|
|
199
|
+
return parsed.success ? parsed.data.shorts : [];
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function available(deps, projectId, output) {
|
|
206
|
+
return (statSync(outputPath(deps.paths, projectId, output.path), {
|
|
207
|
+
throwIfNoEntry: false,
|
|
208
|
+
})?.isFile() === true);
|
|
209
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
4
|
+
import { studioPlaylistMax } from "./model.js";
|
|
5
|
+
// Two rows of the key/value `settings` table. The playlist travels with a backup; the
|
|
6
|
+
// pairing stays on this machine (`slices/storage/portable.ts`): its token is what lets the
|
|
7
|
+
// browser extension read a project's upload pack.
|
|
8
|
+
export const studioPlaylistKey = "studio.playlist";
|
|
9
|
+
export const studioPairingKey = "studio.pairing";
|
|
10
|
+
const pairingSchema = z.object({
|
|
11
|
+
token: z.string().min(20),
|
|
12
|
+
origin: z.string().nullable(),
|
|
13
|
+
pairedAt: z.string().nullable(),
|
|
14
|
+
});
|
|
15
|
+
export function readStudioPlaylist(db) {
|
|
16
|
+
const stored = readSetting(db, studioPlaylistKey);
|
|
17
|
+
if (stored === undefined)
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(stored);
|
|
21
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function studioPlaylistProblem(raw) {
|
|
28
|
+
return raw.trim().length > studioPlaylistMax
|
|
29
|
+
? `The playlist name is longer than YouTube allows (${String(studioPlaylistMax)} characters). Shorten it in Settings → YouTube Studio.`
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
export function saveStudioPlaylist(db, raw) {
|
|
33
|
+
const value = raw.trim();
|
|
34
|
+
if (value === "") {
|
|
35
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(studioPlaylistKey);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
writeSetting(db, studioPlaylistKey, JSON.stringify(value));
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
// The projects whose uploaded clips are real footage (filmed, not generated): ticked in the
|
|
42
|
+
// project's Prepare upload, under AI use. One row holding their ids; YouTube's second AI use
|
|
43
|
+
// case is footage of a real event or place altered by AI (`disclosure.ts`).
|
|
44
|
+
export const studioRealFootageKey = "studio.realFootage";
|
|
45
|
+
function realFootageIds(db) {
|
|
46
|
+
const stored = readSetting(db, studioRealFootageKey);
|
|
47
|
+
if (stored === undefined)
|
|
48
|
+
return [];
|
|
49
|
+
try {
|
|
50
|
+
const value = JSON.parse(stored);
|
|
51
|
+
return Array.isArray(value)
|
|
52
|
+
? value.filter((one) => typeof one === "string")
|
|
53
|
+
: [];
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function readRealFootage(db, projectId) {
|
|
60
|
+
return realFootageIds(db).includes(projectId);
|
|
61
|
+
}
|
|
62
|
+
export function saveRealFootage(db, projectId, on) {
|
|
63
|
+
const others = realFootageIds(db).filter((one) => one !== projectId);
|
|
64
|
+
const next = on ? [...others, projectId].sort() : others;
|
|
65
|
+
if (next.length === 0)
|
|
66
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(studioRealFootageKey);
|
|
67
|
+
else
|
|
68
|
+
writeSetting(db, studioRealFootageKey, JSON.stringify(next));
|
|
69
|
+
}
|
|
70
|
+
// The pairing token, made on first read. One per install: a new one (Settings → YouTube
|
|
71
|
+
// Studio → New pairing token) forgets the extension that used the old one.
|
|
72
|
+
export function studioPairing(db) {
|
|
73
|
+
const stored = readSetting(db, studioPairingKey);
|
|
74
|
+
if (stored !== undefined) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = pairingSchema.safeParse(JSON.parse(stored));
|
|
77
|
+
if (parsed.success)
|
|
78
|
+
return parsed.data;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A damaged row is replaced below.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return resetStudioPairing(db);
|
|
85
|
+
}
|
|
86
|
+
export function resetStudioPairing(db) {
|
|
87
|
+
const pairing = {
|
|
88
|
+
token: randomBytes(24).toString("base64url"),
|
|
89
|
+
origin: null,
|
|
90
|
+
pairedAt: null,
|
|
91
|
+
};
|
|
92
|
+
writeSetting(db, studioPairingKey, JSON.stringify(pairing));
|
|
93
|
+
return pairing;
|
|
94
|
+
}
|
|
95
|
+
// A browser extension's own origin: Chrome's and Firefox's. Web pages never match.
|
|
96
|
+
export function isExtensionOrigin(origin) {
|
|
97
|
+
return (origin !== undefined && /^(chrome-extension|moz-extension):\/\/[a-z0-9-]{1,64}$/.test(origin));
|
|
98
|
+
}
|
|
99
|
+
export function tokenMatches(pairing, given) {
|
|
100
|
+
if (given === undefined)
|
|
101
|
+
return false;
|
|
102
|
+
const a = Buffer.from(pairing.token);
|
|
103
|
+
const b = Buffer.from(given);
|
|
104
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
105
|
+
}
|
|
106
|
+
// The token from an `Authorization: Bearer <token>` header.
|
|
107
|
+
export function bearerToken(header) {
|
|
108
|
+
const match = /^Bearer\s+(\S+)$/.exec(header ?? "");
|
|
109
|
+
return match?.[1];
|
|
110
|
+
}
|
|
111
|
+
// The extension pairs by sending the token from its own origin; that origin is then the only
|
|
112
|
+
// one the app answers across origins, until a new token is made.
|
|
113
|
+
export function pairStudioExtension(db, token, origin, now) {
|
|
114
|
+
const pairing = studioPairing(db);
|
|
115
|
+
if (!tokenMatches(pairing, token))
|
|
116
|
+
return { ok: false, reason: "bad-token" };
|
|
117
|
+
if (!isExtensionOrigin(origin))
|
|
118
|
+
return { ok: false, reason: "not-an-extension" };
|
|
119
|
+
const next = { ...pairing, origin, pairedAt: now.toISOString() };
|
|
120
|
+
writeSetting(db, studioPairingKey, JSON.stringify(next));
|
|
121
|
+
return { ok: true, pairing: next };
|
|
122
|
+
}
|
|
123
|
+
// Whether a request may read upload packs: the right token, and, when the browser names its
|
|
124
|
+
// origin, the paired extension's. A request with no Origin (not from a web page at all) is
|
|
125
|
+
// judged on the token alone.
|
|
126
|
+
export function studioRequestAllowed(db, token, origin) {
|
|
127
|
+
const pairing = studioPairing(db);
|
|
128
|
+
if (!tokenMatches(pairing, token))
|
|
129
|
+
return false;
|
|
130
|
+
if (origin === undefined)
|
|
131
|
+
return true;
|
|
132
|
+
return pairing.origin !== null && origin === pairing.origin;
|
|
133
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { imageBlob } from "../channels/repo.js";
|
|
4
|
+
import { outputPath, stagingPath } from "../storage/layout.js";
|
|
5
|
+
import { outputById, stagedFileById } from "../storage/repo.js";
|
|
6
|
+
// ceiling: a provider's image or an upload is a few MB; anything this large is not a picture
|
|
7
|
+
// worth reading into memory for a 270-pixel preview.
|
|
8
|
+
const maxBytes = 40 * 1024 * 1024;
|
|
9
|
+
// Roles whose file is a single still picture.
|
|
10
|
+
const pictureRoles = new Set(["reference", "image", "thumbnail", "short_image"]);
|
|
11
|
+
export function previewPictures(deps) {
|
|
12
|
+
return (image) => {
|
|
13
|
+
try {
|
|
14
|
+
const bytes = read(deps, image);
|
|
15
|
+
if (bytes === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const extension = pictureExtension(bytes);
|
|
18
|
+
if (extension === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
return { bytes, extension, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function read(deps, image) {
|
|
28
|
+
switch (image.kind) {
|
|
29
|
+
case "picture":
|
|
30
|
+
return imageBlob(deps.db, image.sha256)?.bytes;
|
|
31
|
+
case "upload": {
|
|
32
|
+
const staged = stagedFileById(deps.db, image.stagedFileId);
|
|
33
|
+
if (staged === undefined || staged.state !== "staged")
|
|
34
|
+
return undefined;
|
|
35
|
+
return readSmall(stagingPath(deps.paths, staged.id));
|
|
36
|
+
}
|
|
37
|
+
case "output": {
|
|
38
|
+
const output = outputById(deps.db, image.outputId);
|
|
39
|
+
if (output === undefined || !pictureRoles.has(output.role))
|
|
40
|
+
return undefined;
|
|
41
|
+
return readSmall(outputPath(deps.paths, output.projectId, output.path));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readSmall(path) {
|
|
46
|
+
const size = statSync(path).size;
|
|
47
|
+
if (size === 0 || size > maxBytes)
|
|
48
|
+
return undefined;
|
|
49
|
+
return new Uint8Array(readFileSync(path));
|
|
50
|
+
}
|
|
51
|
+
export function pictureExtension(bytes) {
|
|
52
|
+
const starts = (...values) => values.every((value, at) => bytes[at] === value);
|
|
53
|
+
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
|
54
|
+
return ".png";
|
|
55
|
+
if (starts(0xff, 0xd8, 0xff))
|
|
56
|
+
return ".jpg";
|
|
57
|
+
const ascii = (from, to) => String.fromCharCode(...bytes.subarray(from, to));
|
|
58
|
+
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
|
|
59
|
+
return ".webp";
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defaultMotionStyle, defaultZoomPercent } from "../admission/rules.js";
|
|
4
|
+
import { isMissingFont, resolveFont } from "../fonts/index.js";
|
|
5
|
+
import { serializeAss } from "../subtitles/captions.js";
|
|
6
|
+
import { subtitleFrame } from "../subtitles/layout.js";
|
|
7
|
+
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
8
|
+
import { planRender } from "../video/plan.js";
|
|
9
|
+
import { renderSlideshow } from "../video/slideshow.js";
|
|
10
|
+
import { stylePreviewSeconds } from "./schema.js";
|
|
11
|
+
import { sampleCues } from "./settings.js";
|
|
12
|
+
export class StylePreviewError extends Error {
|
|
13
|
+
reason;
|
|
14
|
+
constructor(reason, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.reason = reason;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// ceiling: small enough to render in a few seconds on a laptop, big enough to judge a font.
|
|
20
|
+
// The short side is 270 px; the captions' script keeps its full-size frame and libass scales
|
|
21
|
+
// it, so a caption sits exactly where it would in the real video.
|
|
22
|
+
const previewFrame = {
|
|
23
|
+
"16:9": { width: 480, height: 270 },
|
|
24
|
+
"9:16": { width: 270, height: 480 },
|
|
25
|
+
};
|
|
26
|
+
const stillSize = 1280;
|
|
27
|
+
const shotSeconds = stylePreviewSeconds / 3;
|
|
28
|
+
const chapterTitle = "Chapter one";
|
|
29
|
+
// Three stills, each a different colour story with a horizon and a light, so motion, grade
|
|
30
|
+
// and transitions all read. Square, so either format crops them to cover.
|
|
31
|
+
const stills = [
|
|
32
|
+
`gradients=s=${stillSize}x${stillSize}:c0=0x1d2b53:c1=0xe07a5f:x0=0:y0=0:x1=0:y1=${stillSize}:d=1,` +
|
|
33
|
+
"drawbox=x=820:y=300:w=140:h=140:color=0xfff1c1@0.9:t=fill," +
|
|
34
|
+
"drawbox=x=0:y=880:w=iw:h=400:color=0x14213d@0.85:t=fill",
|
|
35
|
+
`gradients=s=${stillSize}x${stillSize}:c0=0x0b3d2e:c1=0x88c9a1:x0=0:y0=${stillSize}:x1=${stillSize}:y1=0:d=1,` +
|
|
36
|
+
"drawbox=x=200:y=380:w=260:h=260:color=0xf4f1de@0.8:t=fill," +
|
|
37
|
+
"drawbox=x=0:y=840:w=iw:h=440:color=0x081c15@0.9:t=fill",
|
|
38
|
+
`gradients=s=${stillSize}x${stillSize}:c0=0x3d1e6d:c1=0xf2a65a:x0=${stillSize}:y0=0:x1=0:y1=${stillSize}:d=1,` +
|
|
39
|
+
"drawbox=x=540:y=330:w=200:h=200:color=0xffd166@0.9:t=fill," +
|
|
40
|
+
"drawbox=x=0:y=820:w=iw:h=460:color=0x1b0f2e@0.85:t=fill",
|
|
41
|
+
];
|
|
42
|
+
export function ffmpegStylePreview(deps) {
|
|
43
|
+
let samples;
|
|
44
|
+
const ensureSamples = (signal) => {
|
|
45
|
+
samples ??= makeSamples(deps, signal).catch((error) => {
|
|
46
|
+
samples = undefined;
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
return samples;
|
|
50
|
+
};
|
|
51
|
+
return async (settings, output, signal, picture) => {
|
|
52
|
+
try {
|
|
53
|
+
// A real picture is shown for all three shots: the motion, the Look and the captions read
|
|
54
|
+
// on it, and the transition and chapter card still show at the cuts.
|
|
55
|
+
const images = picture === undefined ? await ensureSamples(signal) : [picture, picture, picture];
|
|
56
|
+
await renderPreview(deps, settings, images, output, signal);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw explained(deps.log, error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function makeSamples(deps, signal) {
|
|
64
|
+
const folder = join(deps.dir, "samples");
|
|
65
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
66
|
+
const paths = [];
|
|
67
|
+
for (const [at, source] of stills.entries()) {
|
|
68
|
+
const path = join(folder, `still-${String(at + 1)}.png`);
|
|
69
|
+
paths.push(path);
|
|
70
|
+
if (existsSync(path))
|
|
71
|
+
continue;
|
|
72
|
+
const part = join(folder, `still-${String(at + 1)}.part.png`);
|
|
73
|
+
await runFfmpeg({
|
|
74
|
+
bin: deps.ffmpeg,
|
|
75
|
+
args: [
|
|
76
|
+
...["-hide_banner", "-nostdin", "-loglevel", "error", "-y"],
|
|
77
|
+
...["-f", "lavfi", "-i", source, "-frames:v", "1", part],
|
|
78
|
+
],
|
|
79
|
+
signal,
|
|
80
|
+
log: deps.log,
|
|
81
|
+
onProgress: () => { },
|
|
82
|
+
});
|
|
83
|
+
renameSync(part, path);
|
|
84
|
+
}
|
|
85
|
+
return paths;
|
|
86
|
+
}
|
|
87
|
+
async function renderPreview(deps, settings, images, output, signal) {
|
|
88
|
+
// A render on a given picture never made the stills, which is what created the folder.
|
|
89
|
+
mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
const workspace = mkdtempSync(join(deps.dir, "work-"));
|
|
91
|
+
try {
|
|
92
|
+
const cardFont = settings.chapterCard === null
|
|
93
|
+
? undefined
|
|
94
|
+
: await resolveFont(deps.paths, settings.chapterCard.fontId);
|
|
95
|
+
const plan = planRender({
|
|
96
|
+
format: settings.format,
|
|
97
|
+
gapSeconds: 0,
|
|
98
|
+
edgeSeconds: 0,
|
|
99
|
+
imageSeconds: shotSeconds,
|
|
100
|
+
zoomPercent: defaultZoomPercent,
|
|
101
|
+
motionStyle: defaultMotionStyle,
|
|
102
|
+
images,
|
|
103
|
+
output,
|
|
104
|
+
edit: {
|
|
105
|
+
...(settings.transition === null ? {} : { transition: settings.transition }),
|
|
106
|
+
look: settings.look,
|
|
107
|
+
...(cardFont === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: {
|
|
110
|
+
cards: {
|
|
111
|
+
// On the cut into the second still, so the transition and the card both show.
|
|
112
|
+
chapters: [{ title: chapterTitle, start: shotSeconds }],
|
|
113
|
+
font: { path: cardFont.path, name: cardFont.assName },
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const edit = {
|
|
119
|
+
...plan.editList,
|
|
120
|
+
...previewFrame[settings.format],
|
|
121
|
+
audio: [{ kind: "gap", path: null, seconds: plan.totalSeconds }],
|
|
122
|
+
};
|
|
123
|
+
const captions = settings.captions;
|
|
124
|
+
if (captions !== null) {
|
|
125
|
+
const font = await resolveFont(deps.paths, captions.fontId);
|
|
126
|
+
mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
|
|
127
|
+
copyFileSync(font.path, join(workspace, "fonts", `selected${font.extension}`));
|
|
128
|
+
writeFileSync(join(workspace, "subtitles.ass"), serializeAss(sampleCues(captions.text, plan.totalSeconds), {
|
|
129
|
+
...subtitleFrame(settings.format),
|
|
130
|
+
fontName: font.assName,
|
|
131
|
+
fontSize: captions.fontSize,
|
|
132
|
+
position: captions.position,
|
|
133
|
+
}), { mode: 0o600 });
|
|
134
|
+
}
|
|
135
|
+
await renderSlideshow({
|
|
136
|
+
bin: deps.ffmpeg,
|
|
137
|
+
edit,
|
|
138
|
+
output,
|
|
139
|
+
burnSubtitles: captions !== null,
|
|
140
|
+
cwd: workspace,
|
|
141
|
+
scratch: workspace,
|
|
142
|
+
signal,
|
|
143
|
+
log: deps.log,
|
|
144
|
+
onProgress: () => { },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function explained(log, error) {
|
|
152
|
+
if (error instanceof StylePreviewError)
|
|
153
|
+
return error;
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
if (isMissingFont(error))
|
|
156
|
+
return new StylePreviewError("font-missing", "The style preview couldn't render because the chosen caption font is no longer available; it may have been uninstalled or deleted. Choose another font under Subtitles, then press Render again.");
|
|
157
|
+
if (message.startsWith("Slopify could not start ffmpeg") || /\bENOENT\b/.test(message))
|
|
158
|
+
return new StylePreviewError("ffmpeg-missing", "The style preview couldn't render because ffmpeg wasn't found. Install ffmpeg, or use the Docker image, then press Render again.");
|
|
159
|
+
log.write("error", "style-preview.render", { detail: message });
|
|
160
|
+
return new StylePreviewError("render-failed", "The style preview couldn't render because ffmpeg stopped with an error. Press Render again; if it fails again, use Download diagnostics in Settings and report it.");
|
|
161
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formats } from "../../kernel/pipeline.js";
|
|
3
|
+
import { videoEditSchema } from "../admission/schema.js";
|
|
4
|
+
import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
|
|
5
|
+
// "See it before you make it": a few seconds rendered by the real renderer with a project's
|
|
6
|
+
// caption and Look settings, on stills and silence Slopify makes itself. Browser-safe, so
|
|
7
|
+
// Play and Edit project read the same request and reply shapes the server checks.
|
|
8
|
+
// How long the preview runs: three stills of two seconds, so a transition and a chapter
|
|
9
|
+
// card both show.
|
|
10
|
+
export const stylePreviewSeconds = 6;
|
|
11
|
+
export const stylePreviewTextMax = 200;
|
|
12
|
+
export const defaultPreviewText = "Every story begins with a word.";
|
|
13
|
+
// A picture the preview is drawn on instead of the sample stills: the establishing image a
|
|
14
|
+
// draft uploaded (its staged file), one a project already has (its output), or a channel cast
|
|
15
|
+
// member's picture (by content hash). One the server can no longer find falls back to the
|
|
16
|
+
// stills rather than failing the preview.
|
|
17
|
+
export const stylePreviewImageSchema = z.discriminatedUnion("kind", [
|
|
18
|
+
z.object({ kind: z.literal("upload"), stagedFileId: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }),
|
|
19
|
+
z.object({ kind: z.literal("output"), outputId: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }),
|
|
20
|
+
z.object({ kind: z.literal("picture"), sha256: z.string().regex(/^[a-f0-9]{64}$/) }),
|
|
21
|
+
]);
|
|
22
|
+
export const stylePreviewRequestSchema = z.object({
|
|
23
|
+
format: z.enum(formats),
|
|
24
|
+
subtitles: z.object({
|
|
25
|
+
mode: z.enum(subtitleModes),
|
|
26
|
+
fontId: z
|
|
27
|
+
.string()
|
|
28
|
+
.max(160)
|
|
29
|
+
.regex(/^[A-Za-z0-9_-]+$/),
|
|
30
|
+
fontSize: z.number().finite().min(16).max(120),
|
|
31
|
+
position: z.enum(subtitlePositions),
|
|
32
|
+
}),
|
|
33
|
+
videoEdit: videoEditSchema.optional(),
|
|
34
|
+
previewText: z.string().max(stylePreviewTextMax).optional(),
|
|
35
|
+
image: stylePreviewImageSchema.optional(),
|
|
36
|
+
// Render again even when this exact preview is already saved.
|
|
37
|
+
force: z.boolean().optional(),
|
|
38
|
+
});
|
|
39
|
+
export const stylePreviewReplySchema = z.object({
|
|
40
|
+
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
41
|
+
// GET it for the mp4; its query changes whenever the file is rendered again.
|
|
42
|
+
url: z.string().min(1),
|
|
43
|
+
cached: z.boolean(),
|
|
44
|
+
seconds: z.number().positive(),
|
|
45
|
+
});
|