videobook 0.0.0-stage → 0.2.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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/bin/videobook.js +7 -0
- package/dist/acestep/client.js +203 -0
- package/dist/acestep/config.js +62 -0
- package/dist/acestep/errors.js +8 -0
- package/dist/acestep/process.js +175 -0
- package/dist/alive-captions/analyze.js +117 -0
- package/dist/alive-captions/audio.js +179 -0
- package/dist/alive-captions/frames.js +59 -0
- package/dist/alive-captions/model.js +37 -0
- package/dist/alive-captions/schema.js +49 -0
- package/dist/alive-captions/track.js +9 -0
- package/dist/api-key.js +33 -0
- package/dist/app-state.js +56 -0
- package/dist/app.js +257 -0
- package/dist/archive-b2.js +332 -0
- package/dist/archive-store.js +169 -0
- package/dist/artifact-label.js +36 -0
- package/dist/artifact-refs.js +49 -0
- package/dist/asset-tag-extraction.js +169 -0
- package/dist/asset-tags.js +228 -0
- package/dist/asset-usage.js +23 -0
- package/dist/audio-cue.js +71 -0
- package/dist/audio-encoding.js +32 -0
- package/dist/audio-sync-request.js +1 -0
- package/dist/audio-sync.js +223 -0
- package/dist/audio-trim-range.js +31 -0
- package/dist/book-context.js +31 -0
- package/dist/book-manager.js +374 -0
- package/dist/catalog-gc.js +26 -0
- package/dist/chain-executor.js +815 -0
- package/dist/chain-generation.js +115 -0
- package/dist/chain-plan.js +746 -0
- package/dist/chain-step-review.js +60 -0
- package/dist/character-name.js +25 -0
- package/dist/chat-generation-routing.js +73 -0
- package/dist/chat-generation-settings.js +78 -0
- package/dist/chat-models.js +44 -0
- package/dist/chat-prompt-rewrite.js +67 -0
- package/dist/chat-router.js +628 -0
- package/dist/chat-view-actions.js +49 -0
- package/dist/cli/args.js +242 -0
- package/dist/cli/config-command.js +103 -0
- package/dist/cli/doctor-command.js +289 -0
- package/dist/cli/entry.js +2 -0
- package/dist/cli/key-report.js +16 -0
- package/dist/cli/main.js +104 -0
- package/dist/cli/mcp-command.js +88 -0
- package/dist/cli/mcp-relay.js +51 -0
- package/dist/cli/open-browser.js +14 -0
- package/dist/cli/run-command.js +231 -0
- package/dist/cli/serve-command.js +62 -0
- package/dist/cli/setup-command.js +60 -0
- package/dist/cli/stdio-guard.js +8 -0
- package/dist/cold-open-model.js +7 -0
- package/dist/cold-open.js +79 -0
- package/dist/command-reference.js +95 -0
- package/dist/content-store.js +74 -0
- package/dist/cost.js +8 -0
- package/dist/crop-geometry.js +23 -0
- package/dist/dancing-workflow.js +447 -0
- package/dist/data-home.js +44 -0
- package/dist/diagnostics.js +109 -0
- package/dist/dialog-quality.js +34 -0
- package/dist/downloads-dest.js +56 -0
- package/dist/editing-moments.js +194 -0
- package/dist/elevenlabs-audio-extension.js +66 -0
- package/dist/engine.js +54 -0
- package/dist/env-file.js +91 -0
- package/dist/env-secrets.js +148 -0
- package/dist/ffmpeg-binary.js +12 -0
- package/dist/ffmpeg.js +425 -0
- package/dist/ffprobe-binary.js +77 -0
- package/dist/forget-asset.js +216 -0
- package/dist/frame-utils.js +27 -0
- package/dist/generation-credit-error.js +11 -0
- package/dist/generation-input-mentions.js +117 -0
- package/dist/generation-input-policy.js +68 -0
- package/dist/generation-reference-mentions.js +97 -0
- package/dist/goal-characters.js +49 -0
- package/dist/goal-plan-v3.js +273 -0
- package/dist/goal-plan.js +220 -0
- package/dist/goal-planner-prompt.js +28 -0
- package/dist/goal-prompt-budget.js +4 -0
- package/dist/goal-reference-images.js +25 -0
- package/dist/goal-state.js +261 -0
- package/dist/goal-stills.js +153 -0
- package/dist/goal-store.js +144 -0
- package/dist/goal-styles.js +144 -0
- package/dist/goal-v3-runtime.js +157 -0
- package/dist/guides/character_schema_guide.md +70 -0
- package/dist/guides/gpt_image_2_5_prompting_guide.md +21 -0
- package/dist/guides/grok_imagine_prompting_guide.md +72 -0
- package/dist/guides/image_reproduction_guide.md +84 -0
- package/dist/guides/kling_3_0_prompting_guide.md +136 -0
- package/dist/guides/nano_banana_pro_prompting_guide.md +28 -0
- package/dist/guides/seedance_2_prompting_guide.md +172 -0
- package/dist/http-access.js +49 -0
- package/dist/http-byte-range.js +31 -0
- package/dist/hydration.js +56 -0
- package/dist/image-compress.js +77 -0
- package/dist/image-edit-default.js +1 -0
- package/dist/image-edit-models.js +41 -0
- package/dist/image-edit-settings.js +11 -0
- package/dist/image-region-edit.js +32 -0
- package/dist/image-subject-map.js +523 -0
- package/dist/image.js +46 -0
- package/dist/index.js +12 -0
- package/dist/job-deadline.js +60 -0
- package/dist/jobs/_alibaba-upload.js +30 -0
- package/dist/jobs/_audio-chunks.js +31 -0
- package/dist/jobs/_download-into-asset.js +15 -0
- package/dist/jobs/_fal-shared.js +22 -0
- package/dist/jobs/_fal-upload.js +35 -0
- package/dist/jobs/_image-edit-adapter.js +213 -0
- package/dist/jobs/_kie-poll.js +119 -0
- package/dist/jobs/_kie-upload.js +32 -0
- package/dist/jobs/_lease.js +163 -0
- package/dist/jobs/_persist-output.js +19 -0
- package/dist/jobs/_storage-retry.js +21 -0
- package/dist/jobs/_transcribe.js +245 -0
- package/dist/jobs/_translate-transcript.js +270 -0
- package/dist/jobs/analyze-alive-captions.js +10 -0
- package/dist/jobs/analyze-editing-moments.js +104 -0
- package/dist/jobs/analyze-jokes.js +143 -0
- package/dist/jobs/analyze-keyword-placement.js +155 -0
- package/dist/jobs/analyze-music.js +14 -0
- package/dist/jobs/analyze-video.js +282 -0
- package/dist/jobs/apply-cuts.js +83 -0
- package/dist/jobs/apply-sfx.js +107 -0
- package/dist/jobs/archive-clip.js +458 -0
- package/dist/jobs/assemble-goal.js +98 -0
- package/dist/jobs/audio-boost.js +71 -0
- package/dist/jobs/audio-mix.js +161 -0
- package/dist/jobs/backfill-asset-tags.js +144 -0
- package/dist/jobs/burn-watermark.js +316 -0
- package/dist/jobs/change-video-speed.js +176 -0
- package/dist/jobs/clean-video-audio.js +207 -0
- package/dist/jobs/cold-open.js +166 -0
- package/dist/jobs/combine-goal.js +108 -0
- package/dist/jobs/complete-provider-task.js +185 -0
- package/dist/jobs/copy-library-asset.js +33 -0
- package/dist/jobs/cost-estimate-assessment.js +113 -0
- package/dist/jobs/crop-image.js +47 -0
- package/dist/jobs/crop-video.js +71 -0
- package/dist/jobs/describe-image.js +181 -0
- package/dist/jobs/detect-scenes.js +33 -0
- package/dist/jobs/doctor-fix.js +100 -0
- package/dist/jobs/download-video.js +427 -0
- package/dist/jobs/enqueue-search-index.js +24 -0
- package/dist/jobs/even-split.js +122 -0
- package/dist/jobs/export-sequence.js +365 -0
- package/dist/jobs/extend-audio.js +229 -0
- package/dist/jobs/extract-audio.js +72 -0
- package/dist/jobs/extract-frame.js +58 -0
- package/dist/jobs/extract-notebook-audio.js +82 -0
- package/dist/jobs/extract-notebook-frame.js +93 -0
- package/dist/jobs/extract-video-clip.js +8 -0
- package/dist/jobs/extract-video-poster.js +55 -0
- package/dist/jobs/extract-waveform.js +25 -0
- package/dist/jobs/finish-goal.js +255 -0
- package/dist/jobs/forget-asset.js +11 -0
- package/dist/jobs/generate-acestep-cover.js +219 -0
- package/dist/jobs/generate-media.js +70 -0
- package/dist/jobs/image-subjects.js +1059 -0
- package/dist/jobs/index.js +18 -0
- package/dist/jobs/inject-first-frame.js +175 -0
- package/dist/jobs/isolate-vocals.js +69 -0
- package/dist/jobs/kie-paygate.js +177 -0
- package/dist/jobs/label-music-sections.js +5 -0
- package/dist/jobs/osg-placement.js +135 -0
- package/dist/jobs/pad-video.js +72 -0
- package/dist/jobs/plan-goal.js +97 -0
- package/dist/jobs/post-record.js +21 -0
- package/dist/jobs/post-to-x.js +21 -0
- package/dist/jobs/prepare-search-reference.js +190 -0
- package/dist/jobs/prepare-temporal-search.js +16 -0
- package/dist/jobs/process-audio.js +67 -0
- package/dist/jobs/process-image.js +58 -0
- package/dist/jobs/process-upload.js +166 -0
- package/dist/jobs/process-video.js +438 -0
- package/dist/jobs/register-all.js +161 -0
- package/dist/jobs/relayout-notebook.js +78 -0
- package/dist/jobs/remove-sound.js +299 -0
- package/dist/jobs/render-project.js +380 -0
- package/dist/jobs/render-qr-outro.js +138 -0
- package/dist/jobs/render-timeline.js +283 -0
- package/dist/jobs/repair-search-indexes.js +42 -0
- package/dist/jobs/replace-video-audio.js +64 -0
- package/dist/jobs/restore-archive-clip.js +53 -0
- package/dist/jobs/reverse-video.js +53 -0
- package/dist/jobs/rewrite-script.js +173 -0
- package/dist/jobs/run-notebook.js +3335 -0
- package/dist/jobs/semantic-index.js +797 -0
- package/dist/jobs/smooth-scene-concat.js +257 -0
- package/dist/jobs/splice-video.js +280 -0
- package/dist/jobs/split-video.js +127 -0
- package/dist/jobs/suggest-cuts.js +149 -0
- package/dist/jobs/suggest-sfx.js +135 -0
- package/dist/jobs/sync-video-to-audio.js +329 -0
- package/dist/jobs/tag-asset.js +266 -0
- package/dist/jobs/task-failure.js +16 -0
- package/dist/jobs/timewarp-video.js +152 -0
- package/dist/jobs/transcribe-video-audio.js +117 -0
- package/dist/jobs/transcribe.js +35 -0
- package/dist/jobs/translate-transcript.js +55 -0
- package/dist/jobs/trim-audio.js +133 -0
- package/dist/jobs/trim-video.js +291 -0
- package/dist/jobs/xai-paygate.js +26 -0
- package/dist/keyword-placement-cache.js +74 -0
- package/dist/lazy-render.js +142 -0
- package/dist/library-browse.js +78 -0
- package/dist/lightning-defaults.js +206 -0
- package/dist/load-env.js +12 -0
- package/dist/local-search-models.js +206 -0
- package/dist/local-upload.js +318 -0
- package/dist/logger.js +69 -0
- package/dist/media/adapters/alibaba.js +20 -0
- package/dist/media/adapters/elevenlabs.js +58 -0
- package/dist/media/adapters/fal.js +45 -0
- package/dist/media/adapters/index.js +26 -0
- package/dist/media/adapters/kie.js +120 -0
- package/dist/media/adapters/pending.js +21 -0
- package/dist/media/adapters/types.js +5 -0
- package/dist/media/adapters/xai.js +18 -0
- package/dist/media/catalog.js +236 -0
- package/dist/media/complete.js +191 -0
- package/dist/media/cost.js +45 -0
- package/dist/media/descriptor.js +157 -0
- package/dist/media/endpoints.js +11 -0
- package/dist/media/frame-options.js +18 -0
- package/dist/media/insert-video-timing.js +17 -0
- package/dist/media/json-schema.js +75 -0
- package/dist/media/legacy-task-types.js +129 -0
- package/dist/media/listing.js +24 -0
- package/dist/media/model-id.js +14 -0
- package/dist/media/preview.js +86 -0
- package/dist/media/prompt-dialects.js +65 -0
- package/dist/media/ref-fields.js +106 -0
- package/dist/media/refs.js +131 -0
- package/dist/media/request.js +188 -0
- package/dist/media/types.js +6 -0
- package/dist/media/wire.js +161 -0
- package/dist/media-library.js +535 -0
- package/dist/media-output-path.js +33 -0
- package/dist/multyculty-workflow.js +757 -0
- package/dist/notebook-action-catalog.js +608 -0
- package/dist/notebook-cell-outputs.js +726 -0
- package/dist/notebook-credit-retry.js +45 -0
- package/dist/notebook-fixtures.js +8 -0
- package/dist/notebook-generation-contract.js +77 -0
- package/dist/notebook-generation-controls.js +145 -0
- package/dist/notebook-goal-layout.js +40 -0
- package/dist/notebook-graph.js +37 -0
- package/dist/notebook-image-edit.js +84 -0
- package/dist/notebook-initial-generation-prompt.js +222 -0
- package/dist/notebook-live-analysis.js +941 -0
- package/dist/notebook-media-ops.js +579 -0
- package/dist/notebook-mutations.js +20 -0
- package/dist/notebook-placement.js +41 -0
- package/dist/notebook-provider-failure.js +87 -0
- package/dist/notebook-published-post.js +26 -0
- package/dist/notebook-reference-resolver.js +227 -0
- package/dist/notebook-relayout.js +270 -0
- package/dist/notebook-run-plan.js +519 -0
- package/dist/notebook-shots.js +162 -0
- package/dist/notebook-workflow.js +856 -0
- package/dist/one-password.js +200 -0
- package/dist/openai-intelligence.js +99 -0
- package/dist/original-media-file.js +12 -0
- package/dist/osg-assets.js +36 -0
- package/dist/osg-placement-cache.js +64 -0
- package/dist/osg-settings.js +27 -0
- package/dist/paid-tools.js +22 -0
- package/dist/platform-guidance.js +26 -0
- package/dist/posts/dropbox-auth.js +91 -0
- package/dist/posts/dropbox-upload.js +132 -0
- package/dist/posts/icloud-drive.js +134 -0
- package/dist/posts/icloud-status.js +64 -0
- package/dist/posts/post-contract.js +2 -0
- package/dist/posts/post-copy-format.js +43 -0
- package/dist/posts/post-copy.js +193 -0
- package/dist/posts/post-folder.js +18 -0
- package/dist/posts/post-metrics.js +119 -0
- package/dist/posts/post-store.js +340 -0
- package/dist/posts/x-post-target.js +43 -0
- package/dist/preview-payload.js +90 -0
- package/dist/prompt-surfaces.js +22 -0
- package/dist/provider-prompt-transform.js +77 -0
- package/dist/provider-status-link.js +21 -0
- package/dist/provider-task-record.js +78 -0
- package/dist/provider-task-status.js +47 -0
- package/dist/provider-usage.js +294 -0
- package/dist/queue.js +810 -0
- package/dist/reference-video-duration.js +17 -0
- package/dist/remotion/BlurFillVideo.js +51 -0
- package/dist/remotion/KeywordGraphicsOverlay.js +68 -0
- package/dist/remotion/OsgOverlay.js +30 -0
- package/dist/remotion/QrOutroComposition.js +47 -0
- package/dist/remotion/RenderComposition.js +11 -0
- package/dist/remotion/browser.js +59 -0
- package/dist/remotion/constants.js +35 -0
- package/dist/remotion/index.js +66 -0
- package/dist/remotion/keyword-graphics.js +151 -0
- package/dist/remotion/keyword-placement.js +182 -0
- package/dist/remotion/osg-spec.js +198 -0
- package/dist/remotion/public/qr-outro-impact.wav +0 -0
- package/dist/remotion/qr-outro-audio.js +1 -0
- package/dist/remotion/qr-outro-spec.js +211 -0
- package/dist/remotion/shared-bundle.js +37 -0
- package/dist/remotion/subtitles/AliveCaptions.js +83 -0
- package/dist/remotion/subtitles/Highlight.js +58 -0
- package/dist/remotion/subtitles/Impact.js +67 -0
- package/dist/remotion/subtitles/SubtitleOverlay.js +9 -0
- package/dist/remotion/subtitles/alive-layout.js +321 -0
- package/dist/remotion/subtitles/alive-types.js +17 -0
- package/dist/remotion/subtitles/chunker.js +68 -0
- package/dist/remotion/subtitles/segments.js +42 -0
- package/dist/remotion/subtitles/styles.js +4 -0
- package/dist/remotion/subtitles/types.js +1 -0
- package/dist/remotion/util.js +18 -0
- package/dist/remotion-cancellation.js +15 -0
- package/dist/rename-helpers.js +204 -0
- package/dist/rhythm/analyze.js +128 -0
- package/dist/rhythm/contract.js +14 -0
- package/dist/rhythm/draft-model.js +19 -0
- package/dist/rhythm/energy.js +133 -0
- package/dist/rhythm/fft.js +44 -0
- package/dist/rhythm/model.js +71 -0
- package/dist/rhythm/music-analysis.js +124 -0
- package/dist/rhythm/music-draft.js +261 -0
- package/dist/rhythm/onset.js +101 -0
- package/dist/rhythm/run-worker.js +51 -0
- package/dist/rhythm/score.js +47 -0
- package/dist/rhythm/section-labels.js +168 -0
- package/dist/rhythm/sequence-rhythm.js +67 -0
- package/dist/rhythm/synthesize.js +103 -0
- package/dist/rhythm/timing.js +98 -0
- package/dist/rhythm/track.js +320 -0
- package/dist/rhythm/visual-fit.js +4 -0
- package/dist/rhythm/worker.js +25 -0
- package/dist/scene-detect.js +200 -0
- package/dist/scene-transition.js +12 -0
- package/dist/scheduled-posts.js +200 -0
- package/dist/search-index-coverage.js +13 -0
- package/dist/search-index-media.js +71 -0
- package/dist/search-reference-store.js +60 -0
- package/dist/search-scoring.js +17 -0
- package/dist/serve.js +125 -0
- package/dist/server-lock.js +143 -0
- package/dist/server.js +42 -0
- package/dist/srt.js +16 -0
- package/dist/tag-backfill-state.js +121 -0
- package/dist/tag-settings.js +30 -0
- package/dist/tag-vocabulary.js +237 -0
- package/dist/test-queue-cleanup.js +1 -0
- package/dist/time-parse.js +50 -0
- package/dist/timeline-helpers.js +554 -0
- package/dist/timewarp.js +87 -0
- package/dist/tools/_image-inputs.js +9 -0
- package/dist/tools/_media-refs.js +84 -0
- package/dist/tools/_shared.js +59 -0
- package/dist/tools/_suggest-shared.js +279 -0
- package/dist/tools/add-timeline-audio.js +73 -0
- package/dist/tools/alive-captions.js +157 -0
- package/dist/tools/analyze-jokes.js +56 -0
- package/dist/tools/analyze-video.js +66 -0
- package/dist/tools/animate-keywords.js +217 -0
- package/dist/tools/api-status.js +42 -0
- package/dist/tools/application-commands.js +34 -0
- package/dist/tools/apply-cuts.js +112 -0
- package/dist/tools/apply-sfx.js +152 -0
- package/dist/tools/apply-watermark.js +670 -0
- package/dist/tools/archive-clip.js +74 -0
- package/dist/tools/asset-history.js +61 -0
- package/dist/tools/asset-manifest.js +24 -0
- package/dist/tools/asset-tags.js +151 -0
- package/dist/tools/assign-slot.js +97 -0
- package/dist/tools/audio-boost.js +107 -0
- package/dist/tools/audio-mix.js +121 -0
- package/dist/tools/book-usage.js +16 -0
- package/dist/tools/chain.js +136 -0
- package/dist/tools/change-video-speed.js +105 -0
- package/dist/tools/chat-history.js +5 -0
- package/dist/tools/chat.js +848 -0
- package/dist/tools/clean-video-audio.js +97 -0
- package/dist/tools/clear-failed-tasks.js +66 -0
- package/dist/tools/cold-open.js +114 -0
- package/dist/tools/command-plan.js +1680 -0
- package/dist/tools/commands.js +1666 -0
- package/dist/tools/concat-images.js +236 -0
- package/dist/tools/connect-service.js +55 -0
- package/dist/tools/copy-file-to-clipboard.js +58 -0
- package/dist/tools/create-character.js +114 -0
- package/dist/tools/create-qart.js +253 -0
- package/dist/tools/create-qr-outro.js +149 -0
- package/dist/tools/create-qrbtf.js +215 -0
- package/dist/tools/create-watermark.js +97 -0
- package/dist/tools/crop-asset.js +18 -0
- package/dist/tools/crop-image.js +120 -0
- package/dist/tools/crop-video.js +97 -0
- package/dist/tools/delete-asset.js +104 -0
- package/dist/tools/describe-image.js +69 -0
- package/dist/tools/detect-scenes.js +44 -0
- package/dist/tools/doctor.js +126 -0
- package/dist/tools/download-video.js +114 -0
- package/dist/tools/duplicate-asset.js +184 -0
- package/dist/tools/edit-timeline.js +59 -0
- package/dist/tools/editing-moments.js +105 -0
- package/dist/tools/estimate-cost.js +171 -0
- package/dist/tools/even-split.js +295 -0
- package/dist/tools/export-asset-file.js +22 -0
- package/dist/tools/export-asset.js +140 -0
- package/dist/tools/export-sequence.js +250 -0
- package/dist/tools/export-to-downloads.js +54 -0
- package/dist/tools/export-transcript-srt.js +79 -0
- package/dist/tools/extend-audio.js +100 -0
- package/dist/tools/extract-audio.js +174 -0
- package/dist/tools/extract-frame.js +108 -0
- package/dist/tools/extract-notebook-audio.js +173 -0
- package/dist/tools/extract-notebook-frame.js +160 -0
- package/dist/tools/extract-video-clip.js +101 -0
- package/dist/tools/generate-media.js +157 -0
- package/dist/tools/generation-payload.js +48 -0
- package/dist/tools/get-timeline.js +12 -0
- package/dist/tools/get-watermark-config.js +38 -0
- package/dist/tools/goal.js +644 -0
- package/dist/tools/image-subjects.js +711 -0
- package/dist/tools/index.js +323 -0
- package/dist/tools/inject-first-frame.js +154 -0
- package/dist/tools/invert-image.js +70 -0
- package/dist/tools/isolate-vocals.js +66 -0
- package/dist/tools/list-assets.js +98 -0
- package/dist/tools/list-cell-generations.js +225 -0
- package/dist/tools/list-elevenlabs-voices.js +163 -0
- package/dist/tools/log-wrapper.js +29 -0
- package/dist/tools/make-cover-song.js +95 -0
- package/dist/tools/mark-asset-seen.js +23 -0
- package/dist/tools/media-library.js +245 -0
- package/dist/tools/move-timeline-audio.js +35 -0
- package/dist/tools/music-analysis.js +60 -0
- package/dist/tools/music-draft.js +29 -0
- package/dist/tools/pad-video.js +87 -0
- package/dist/tools/post-record.js +83 -0
- package/dist/tools/post.js +168 -0
- package/dist/tools/prompt-history.js +164 -0
- package/dist/tools/provider-key-status.js +248 -0
- package/dist/tools/qrbtf-render.js +849 -0
- package/dist/tools/read-asset-metadata.js +26 -0
- package/dist/tools/read-file-at-commit.js +29 -0
- package/dist/tools/read-transcript.js +41 -0
- package/dist/tools/reconcile-project-work.js +1452 -0
- package/dist/tools/recover-asset.js +28 -0
- package/dist/tools/remove-sound.js +72 -0
- package/dist/tools/remove-timeline-audio.js +34 -0
- package/dist/tools/remove-timeline-slot.js +40 -0
- package/dist/tools/rename-all.js +434 -0
- package/dist/tools/rename-asset.js +34 -0
- package/dist/tools/render-settings.js +47 -0
- package/dist/tools/render-timeline.js +120 -0
- package/dist/tools/render.js +384 -0
- package/dist/tools/reorder-character-images.js +45 -0
- package/dist/tools/replace-video-audio.js +133 -0
- package/dist/tools/restore-archive-clips.js +209 -0
- package/dist/tools/restore-asset-version.js +37 -0
- package/dist/tools/retry-generation.js +54 -0
- package/dist/tools/reverse-video.js +74 -0
- package/dist/tools/rewrite-prompt.js +388 -0
- package/dist/tools/rewrite-script.js +67 -0
- package/dist/tools/schema-from-apicity.js +66 -0
- package/dist/tools/search-archive.js +150 -0
- package/dist/tools/semantic-search.js +1214 -0
- package/dist/tools/sequence-rhythm.js +43 -0
- package/dist/tools/set-character-metadata.js +84 -0
- package/dist/tools/set-env-secret.js +40 -0
- package/dist/tools/set-osg.js +143 -0
- package/dist/tools/show-folder.js +37 -0
- package/dist/tools/smooth-scene-concat.js +122 -0
- package/dist/tools/splice-video.js +149 -0
- package/dist/tools/split-video.js +202 -0
- package/dist/tools/suggest-cuts.js +45 -0
- package/dist/tools/suggest-sfx.js +43 -0
- package/dist/tools/sync-video-to-audio.js +115 -0
- package/dist/tools/tag-backfill.js +177 -0
- package/dist/tools/timewarp-video.js +85 -0
- package/dist/tools/tool-def.js +1 -0
- package/dist/tools/tool-json-schema.js +116 -0
- package/dist/tools/transcribe.js +90 -0
- package/dist/tools/translate-subtitles.js +59 -0
- package/dist/tools/trim-audio.js +139 -0
- package/dist/tools/trim-video.js +168 -0
- package/dist/tools/update-script.js +25 -0
- package/dist/tools/update-timeline-audio.js +40 -0
- package/dist/tools/upload-file.js +41 -0
- package/dist/tools/verify-x-mentions.js +141 -0
- package/dist/tools/video-editing-command-contract.js +291 -0
- package/dist/tools/video-source-reference.js +30 -0
- package/dist/tools/video-time-input.js +18 -0
- package/dist/tools/videobook-domain.js +2021 -0
- package/dist/tools/wait-for-asset-ready.js +143 -0
- package/dist/tools/workspace-actions.js +54 -0
- package/dist/transcript-projection.js +202 -0
- package/dist/transcript-srt.js +33 -0
- package/dist/upload.js +76 -0
- package/dist/usage-providers.js +25 -0
- package/dist/video-analysis-projection.js +46 -0
- package/dist/video-poster.js +33 -0
- package/dist/video-proxy.js +99 -0
- package/dist/vision-regions.js +111 -0
- package/dist/voice-actions.js +90 -0
- package/dist/voice-audio.js +55 -0
- package/dist/voice-config.js +122 -0
- package/dist/voice-continuation.js +54 -0
- package/dist/voice-contract.js +1 -0
- package/dist/voice-http.js +145 -0
- package/dist/voice-reply-progress.js +55 -0
- package/dist/voice-session.js +992 -0
- package/dist/watermark-badge.js +259 -0
- package/dist/watermark-config.js +111 -0
- package/dist/watermark-handle-position.js +55 -0
- package/dist/watermark-platform.js +15 -0
- package/dist/watermark-timing.js +16 -0
- package/dist/workspace-action-catalog.js +315 -0
- package/dist/workspace-action-contract.js +21 -0
- package/dist/workspace-action-groups.js +98 -0
- package/dist/workspace-action-surfaces.js +7 -0
- package/dist/x/account.js +88 -0
- package/dist/x/authenticated-handle.js +30 -0
- package/dist/x/oauth-flow.js +191 -0
- package/dist/x/post-contract.js +1 -0
- package/dist/x/post-metadata.js +44 -0
- package/dist/x/post-to-x.js +456 -0
- package/dist/x/published-post-cell.js +90 -0
- package/dist/x/x-tokens.js +69 -0
- package/dist/ytdlp-binary.js +65 -0
- package/dist/zod-slots.js +334 -0
- package/package.json +84 -4
- package/ui/assets/alive-captions-BWb5nLKj.css +1 -0
- package/ui/assets/alive-captions-DPEr1nKM.js +1 -0
- package/ui/assets/confirm-bodies-D8ouHXKy.js +1 -0
- package/ui/assets/confirm-bodies-DrMhaXC6.css +1 -0
- package/ui/assets/fullscreen-B7nw4YbE.js +1 -0
- package/ui/assets/goal-ui-DK-xTCK0.js +2 -0
- package/ui/assets/goal-ui-DXdUM2KF.css +1 -0
- package/ui/assets/index-DzPAnCUj.css +1 -0
- package/ui/assets/index-kS1905s5.js +37 -0
- package/ui/assets/inspector-MlzQbTVo.js +1 -0
- package/ui/assets/jobs-ui-CoinOWx0.js +1 -0
- package/ui/assets/music-video-Cbe80kmQ.js +1 -0
- package/ui/assets/music-video-DPaiKKEq.css +1 -0
- package/ui/assets/ui-Oirr9VOf.css +1 -0
- package/ui/assets/ui-xo_Y1yQg.js +2 -0
- package/ui/assets/vendor-D2rfiZ5j.css +1 -0
- package/ui/assets/vendor-DfTCWgKm.js +19 -0
- package/ui/assets/vendor-flow-BXbeWxW7.js +7 -0
- package/ui/assets/vendor-flow-BnuhLJ6X.css +1 -0
- package/ui/assets/vendor-icons-CGqhCIXV.js +1 -0
- package/ui/assets/vendor-mcp-BTlEJcHV.js +67 -0
- package/ui/assets/vendor-react-Dj7D1fD4.js +9 -0
- package/ui/assets/vendor-remotion-CH1HXrlc.js +95 -0
- package/ui/assets/videobook-Qlp9HQFu.js +20 -0
- package/ui/favicon.svg +8 -0
- package/ui/index.html +126 -0
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
import { createKie, createXai, createAlibaba, createFal } from "../usage-providers.js";
|
|
2
|
+
import { describeFalError } from "../jobs/_fal-shared.js";
|
|
3
|
+
import { currentBookContext, withBookContext } from "../book-context.js";
|
|
4
|
+
import { settleFinishedUsage } from "../provider-usage.js";
|
|
5
|
+
import { legacyMediaForTaskType } from "../media/legacy-task-types.js";
|
|
6
|
+
import { parseModelId } from "../media/model-id.js";
|
|
7
|
+
import { computeArtifactStatus, QUEUED_TASK_ID } from "videobook-engine";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { KieError } from "@apicity/kie";
|
|
12
|
+
import { XaiError } from "@apicity/xai";
|
|
13
|
+
import { AlibabaError } from "@apicity/alibaba";
|
|
14
|
+
import { FalError } from "@apicity/fal";
|
|
15
|
+
import { EMPTY_TAG_SUMMARY, readAssetTagSummaries, } from "../asset-tags.js";
|
|
16
|
+
import { logger } from "../logger.js";
|
|
17
|
+
import { queue } from "../queue.js";
|
|
18
|
+
import { getApiKey } from "../api-key.js";
|
|
19
|
+
import { findVideoFile, findAudioFile, findPrimaryMediaFile, RENDER_ORIENTATIONS as MEDIA_RENDER_ORIENTATIONS, } from "videobook-engine";
|
|
20
|
+
import { readAndAssessDialog } from "../dialog-quality.js";
|
|
21
|
+
import { hasSpokenAudio, PLAIN_TRANSCRIPT_FILE, readAssetTranscriptSources, TIMED_TRANSCRIPT_FILE, } from "../transcript-projection.js";
|
|
22
|
+
import { isAnalyzableVideo, readVideoAnalysisSummary, VIDEO_ANALYSIS_FILE, } from "../video-analysis-projection.js";
|
|
23
|
+
import { failPendingTaskAndLog } from "../jobs/task-failure.js";
|
|
24
|
+
import { findUnprocessedUploads, processExternalUpload } from "../jobs/process-upload.js";
|
|
25
|
+
import { planVideoTranscriptJobs } from "../jobs/process-video.js";
|
|
26
|
+
import { probeVideo } from "../ffmpeg.js";
|
|
27
|
+
import { providerEndpoints } from "../media/endpoints.js";
|
|
28
|
+
import { submitQueuedKieTask } from "../jobs/kie-paygate.js";
|
|
29
|
+
import { DEFAULT_KEYWORD_GRAPHICS_LINGER_SECONDS, DEFAULT_KEYWORD_GRAPHICS_MAX_COUNT, } from "../remotion/keyword-graphics.js";
|
|
30
|
+
import { invalidVideoProxyMetadata, probeStoredVideoFile, videoProxyAssessmentMatches, videoProxyMetadata, videoProxyMetadataMatches, videoProxyValidationError, } from "../video-proxy.js";
|
|
31
|
+
/** Upper bound on how long a pending task may sit without reaching success/fail
|
|
32
|
+
* before reconciliation ages it out. Guards against providers leaving jobs in an
|
|
33
|
+
* intermediate "waiting" state indefinitely. */
|
|
34
|
+
const PENDING_TASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
+
function analysisInfoFromMetadata(metadata) {
|
|
36
|
+
const value = metadata?.analysisInfo;
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
38
|
+
return null;
|
|
39
|
+
const record = value;
|
|
40
|
+
const sourceDurationSeconds = record.sourceDurationSeconds;
|
|
41
|
+
if (typeof sourceDurationSeconds !== "number"
|
|
42
|
+
|| !Number.isFinite(sourceDurationSeconds)
|
|
43
|
+
|| !Array.isArray(record.shots)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const shots = record.shots.flatMap((value) => {
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
48
|
+
return [];
|
|
49
|
+
const shot = value;
|
|
50
|
+
if (typeof shot.ordinal !== "number"
|
|
51
|
+
|| typeof shot.label !== "string"
|
|
52
|
+
|| typeof shot.startSeconds !== "number"
|
|
53
|
+
|| typeof shot.endSeconds !== "number"
|
|
54
|
+
|| typeof shot.durationSeconds !== "number") {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return [{
|
|
58
|
+
ordinal: shot.ordinal,
|
|
59
|
+
label: shot.label,
|
|
60
|
+
start_seconds: shot.startSeconds,
|
|
61
|
+
end_seconds: shot.endSeconds,
|
|
62
|
+
duration_seconds: shot.durationSeconds,
|
|
63
|
+
}];
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
source_duration_seconds: sourceDurationSeconds,
|
|
67
|
+
output_json: null,
|
|
68
|
+
output_url: null,
|
|
69
|
+
llm_exchanges: [],
|
|
70
|
+
shots,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function analysisOutputProjection(engine, artifactId) {
|
|
74
|
+
const read = await engine.files.read(artifactId, "analysis.json");
|
|
75
|
+
if (!read.ok)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const outputJson = JSON.parse(read.value.toString());
|
|
79
|
+
if (typeof outputJson !== "object"
|
|
80
|
+
|| outputJson === null
|
|
81
|
+
|| Array.isArray(outputJson)) {
|
|
82
|
+
return { outputJson, llmExchanges: [] };
|
|
83
|
+
}
|
|
84
|
+
const output = outputJson;
|
|
85
|
+
const exchanges = Array.isArray(output.llmExchanges)
|
|
86
|
+
? output.llmExchanges.flatMap((value) => {
|
|
87
|
+
if (typeof value !== "object"
|
|
88
|
+
|| value === null
|
|
89
|
+
|| Array.isArray(value)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const exchange = value;
|
|
93
|
+
const input = typeof exchange.input === "object"
|
|
94
|
+
&& exchange.input !== null
|
|
95
|
+
&& !Array.isArray(exchange.input)
|
|
96
|
+
? exchange.input
|
|
97
|
+
: null;
|
|
98
|
+
const provider = typeof exchange.provider === "string"
|
|
99
|
+
? exchange.provider
|
|
100
|
+
: null;
|
|
101
|
+
const model = typeof exchange.model === "string"
|
|
102
|
+
? exchange.model
|
|
103
|
+
: typeof input?.model === "string"
|
|
104
|
+
? input.model
|
|
105
|
+
: null;
|
|
106
|
+
const stage = typeof exchange.stage === "string"
|
|
107
|
+
? exchange.stage
|
|
108
|
+
: null;
|
|
109
|
+
const text = input
|
|
110
|
+
&& typeof input.text === "object"
|
|
111
|
+
&& input.text !== null
|
|
112
|
+
&& !Array.isArray(input.text)
|
|
113
|
+
? input.text
|
|
114
|
+
: null;
|
|
115
|
+
const format = text
|
|
116
|
+
&& typeof text.format === "object"
|
|
117
|
+
&& text.format !== null
|
|
118
|
+
&& !Array.isArray(text.format)
|
|
119
|
+
? text.format
|
|
120
|
+
: null;
|
|
121
|
+
const formatType = typeof format?.type === "string"
|
|
122
|
+
? format.type
|
|
123
|
+
: null;
|
|
124
|
+
const responseFormat = formatType
|
|
125
|
+
? {
|
|
126
|
+
type: formatType,
|
|
127
|
+
name: typeof format?.name === "string" ? format.name : null,
|
|
128
|
+
strict: format?.strict === true,
|
|
129
|
+
}
|
|
130
|
+
: null;
|
|
131
|
+
return provider && model && stage
|
|
132
|
+
? [{
|
|
133
|
+
provider,
|
|
134
|
+
model,
|
|
135
|
+
stage,
|
|
136
|
+
response_format: responseFormat,
|
|
137
|
+
}]
|
|
138
|
+
: [];
|
|
139
|
+
})
|
|
140
|
+
: [];
|
|
141
|
+
const compactOutput = { ...output };
|
|
142
|
+
delete compactOutput.llmExchanges;
|
|
143
|
+
return {
|
|
144
|
+
outputJson: compactOutput,
|
|
145
|
+
llmExchanges: exchanges,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function findAssetMediaFile(files, asset) {
|
|
153
|
+
return asset.kind === "audio"
|
|
154
|
+
? findAudioFile(files, asset.kind)
|
|
155
|
+
: asset.kind === "video"
|
|
156
|
+
? findVideoFile(files.filter((file) => file.name !== "proxy.mp4"))
|
|
157
|
+
: findPrimaryMediaFile(files, asset.kind);
|
|
158
|
+
}
|
|
159
|
+
async function assessStoredVideoProxy(engine, assetId, sourceFile, proxyFile, sourceProfile) {
|
|
160
|
+
let assessment;
|
|
161
|
+
try {
|
|
162
|
+
const proxy = await probeStoredVideoFile(engine, assetId, proxyFile.name);
|
|
163
|
+
const error = videoProxyValidationError(sourceProfile, proxy);
|
|
164
|
+
assessment = error
|
|
165
|
+
? invalidVideoProxyMetadata(sourceFile, proxyFile, error)
|
|
166
|
+
: videoProxyMetadata(sourceFile, proxyFile, proxy);
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
const error = cause instanceof Error ? cause.message : String(cause);
|
|
170
|
+
assessment = invalidVideoProxyMetadata(sourceFile, proxyFile, error);
|
|
171
|
+
}
|
|
172
|
+
await engine.metadata.artifacts.write(assetId, "proxy", assessment);
|
|
173
|
+
return assessment.valid;
|
|
174
|
+
}
|
|
175
|
+
/** Read the OSG overlay summary from the asset's render metadata. OSG is stored
|
|
176
|
+
* identically across orientations, so the first orientation that carries an
|
|
177
|
+
* osgAssetId wins. Returns null when no overlay is configured. */
|
|
178
|
+
async function readOsgSummary(engine, assetId) {
|
|
179
|
+
for (const orient of RENDER_ORIENTATIONS) {
|
|
180
|
+
const meta = await engine.metadata.artifacts.read(assetId, `${orient}.render`);
|
|
181
|
+
if (!meta.ok)
|
|
182
|
+
continue;
|
|
183
|
+
const osgAssetId = meta.value.osgAssetId;
|
|
184
|
+
if (typeof osgAssetId !== "string" || !osgAssetId)
|
|
185
|
+
continue;
|
|
186
|
+
return {
|
|
187
|
+
assetId: osgAssetId,
|
|
188
|
+
position: typeof meta.value.osgPosition === "string" ? meta.value.osgPosition : "top-right",
|
|
189
|
+
size: typeof meta.value.osgSize === "string" ? meta.value.osgSize : "medium",
|
|
190
|
+
scale: typeof meta.value.osgScale === "number" ? meta.value.osgScale : 1,
|
|
191
|
+
opacity: typeof meta.value.osgOpacity === "number" ? meta.value.osgOpacity : 1,
|
|
192
|
+
startSeconds: typeof meta.value.osgStartSeconds === "number" ? meta.value.osgStartSeconds : null,
|
|
193
|
+
endSeconds: typeof meta.value.osgEndSeconds === "number" ? meta.value.osgEndSeconds : null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
function metaPrompt(meta) {
|
|
199
|
+
if (!meta)
|
|
200
|
+
return null;
|
|
201
|
+
const p = meta.prompt;
|
|
202
|
+
return typeof p === "string" ? p : null;
|
|
203
|
+
}
|
|
204
|
+
function isVeoPendingTask(task) {
|
|
205
|
+
return typeof task.meta?.veoModel === "string";
|
|
206
|
+
}
|
|
207
|
+
function isKiePendingTask(task) {
|
|
208
|
+
if (isVeoPendingTask(task))
|
|
209
|
+
return false;
|
|
210
|
+
const model = task.meta?.model;
|
|
211
|
+
if (typeof model === "string" && model.includes(":") && parseModelId(model)?.provider !== "kie")
|
|
212
|
+
return false;
|
|
213
|
+
return task.taskType === "media_kie" || task.taskType === "isolate_vocals"
|
|
214
|
+
|| (legacyMediaForTaskType(task.taskType) !== null && !/^(?:xai|fal|alibaba)_/u.test(task.taskType));
|
|
215
|
+
}
|
|
216
|
+
function isQueuedKiePendingTask(task) {
|
|
217
|
+
return task.taskId === QUEUED_TASK_ID && isKiePendingTask(task);
|
|
218
|
+
}
|
|
219
|
+
/** failCode "500" is kie's transient internal error ("please try again
|
|
220
|
+
* later"). It's only worth leaving the task pending for a re-poll while the
|
|
221
|
+
* job is still in flight — once kie stamps a `completeTime`, the task record
|
|
222
|
+
* is final and re-polling the same taskId can never flip to success. Treating
|
|
223
|
+
* a completed 500 as transient strands the asset in "working" until the 30m
|
|
224
|
+
* reconciliation timeout (PENDING_TASK_TIMEOUT_MS) and buries the real failure message,
|
|
225
|
+
* so only a 500 with no completeTime is considered retryable. */
|
|
226
|
+
export function isTransientKieFailure(data) {
|
|
227
|
+
if (data.failCode !== "500")
|
|
228
|
+
return false;
|
|
229
|
+
return !(typeof data.completeTime === "number" && data.completeTime > 0);
|
|
230
|
+
}
|
|
231
|
+
function buildFrameDirs(directories, bookId, assetId) {
|
|
232
|
+
const out = {};
|
|
233
|
+
if (!directories)
|
|
234
|
+
return out;
|
|
235
|
+
for (const [dir, fileList] of Object.entries(directories)) {
|
|
236
|
+
if (!dir.endsWith("_frames"))
|
|
237
|
+
continue;
|
|
238
|
+
out[dir] = fileList
|
|
239
|
+
.filter((f) => f.endsWith(".jpg"))
|
|
240
|
+
.sort()
|
|
241
|
+
.map((f) => `/files/${bookId}/${assetId}/${dir}/${f}`);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
const WAVEFORM_SOURCE_RE = /^original\.(mp4|mov|mp3|wav|m4a|aac|ogg|flac)$/i;
|
|
246
|
+
async function readWaveformOrSchedule(engine, assetId, bookId, videoFilename) {
|
|
247
|
+
if (!videoFilename || !WAVEFORM_SOURCE_RE.test(videoFilename)) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const existing = await engine.metadata.waveforms.read(assetId);
|
|
251
|
+
if (existing.ok) {
|
|
252
|
+
// Empty peaks are a negative-cache marker ("extracted, nothing usable"):
|
|
253
|
+
// surface them as no-waveform so the UI is unchanged, but don't re-extract.
|
|
254
|
+
return existing.value.peaks.length > 0 ? existing.value.peaks : null;
|
|
255
|
+
}
|
|
256
|
+
// Waveform extraction shells out to ffmpeg and can take several seconds for
|
|
257
|
+
// long videos, so it must never run inline in list_assets / reconcile_project_work. Hand it to
|
|
258
|
+
// the job queue (deduped per asset) and return no-waveform now; the UI fills
|
|
259
|
+
// it in on a later poll once the background job has persisted the peaks.
|
|
260
|
+
scheduleWaveformExtraction(bookId, assetId, videoFilename);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
function scheduleWaveformExtraction(bookId, assetId, videoFilename) {
|
|
264
|
+
const jobId = `extract-waveform-${bookId}-${assetId}`;
|
|
265
|
+
void queue
|
|
266
|
+
.enqueue(jobId, {
|
|
267
|
+
type: "extract_waveform",
|
|
268
|
+
bookId,
|
|
269
|
+
waveformAssetId: assetId,
|
|
270
|
+
videoFilename,
|
|
271
|
+
})
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
logger.error("waveform-enqueue-failed", { assetId, error: err });
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const PARTIAL_DOWNLOAD_RE = /\.(mp4|mov)\.part$/i;
|
|
277
|
+
/** Media extensions whose duration can be recovered via ffprobe when metadata lacks it. */
|
|
278
|
+
const DURATION_PROBE_EXT_RE = /\.(mp4|mov|webm|mkv|mp3|wav|m4a|aac|ogg|flac)$/i;
|
|
279
|
+
export const RENDER_ORIENTATIONS = MEDIA_RENDER_ORIENTATIONS;
|
|
280
|
+
export async function readRenderSettings(engine, assetId, orientations) {
|
|
281
|
+
const result = {};
|
|
282
|
+
await Promise.all(orientations.map(async (orient) => {
|
|
283
|
+
const meta = await engine.metadata.artifacts.read(assetId, `${orient}.render`);
|
|
284
|
+
if (meta.ok)
|
|
285
|
+
result[orient] = meta.value;
|
|
286
|
+
}));
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
const BASE_DEFAULTS = {
|
|
290
|
+
subtitlesEnabled: false,
|
|
291
|
+
subtitleLanguage: "original",
|
|
292
|
+
position: "bottom",
|
|
293
|
+
fontSize: "medium",
|
|
294
|
+
audioFadeIn: 0,
|
|
295
|
+
audioFadeOut: 0,
|
|
296
|
+
audioVolume: 100,
|
|
297
|
+
osgImagePath: null,
|
|
298
|
+
osgAssetId: null,
|
|
299
|
+
osgPosition: "top-right",
|
|
300
|
+
osgSize: "medium",
|
|
301
|
+
osgScale: 1,
|
|
302
|
+
osgOpacity: 1,
|
|
303
|
+
osgStartSeconds: null,
|
|
304
|
+
osgEndSeconds: null,
|
|
305
|
+
osgTailSeconds: null,
|
|
306
|
+
keywordGraphicsEnabled: false,
|
|
307
|
+
keywordGraphicsMaxCount: DEFAULT_KEYWORD_GRAPHICS_MAX_COUNT,
|
|
308
|
+
keywordGraphicsLingerSeconds: DEFAULT_KEYWORD_GRAPHICS_LINGER_SECONDS,
|
|
309
|
+
keywordGraphicsKeywords: null,
|
|
310
|
+
backgroundMode: "blur",
|
|
311
|
+
strokeWidth: 6,
|
|
312
|
+
strokeColor: "#000000",
|
|
313
|
+
shadowBlur: 6,
|
|
314
|
+
shadowOpacity: 60,
|
|
315
|
+
};
|
|
316
|
+
const ORIENTATION_OVERRIDES = {
|
|
317
|
+
portrait: {
|
|
318
|
+
highlightStyle: "impact",
|
|
319
|
+
textColor: "#FFFFFF",
|
|
320
|
+
highlightColor: "#FFFF00",
|
|
321
|
+
},
|
|
322
|
+
landscape: {
|
|
323
|
+
highlightStyle: "highlight",
|
|
324
|
+
textColor: "#FFFFFF",
|
|
325
|
+
highlightColor: "#991B1B",
|
|
326
|
+
},
|
|
327
|
+
square: {
|
|
328
|
+
highlightStyle: "highlight",
|
|
329
|
+
textColor: "#FFFFFF",
|
|
330
|
+
highlightColor: "#991B1B",
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
export async function readStoredRenderDefaults(engine, assetId, targetOrientation) {
|
|
334
|
+
const defaults = { ...BASE_DEFAULTS, ...(ORIENTATION_OVERRIDES[targetOrientation] ?? ORIENTATION_OVERRIDES.landscape) };
|
|
335
|
+
// Read every orientation's render metadata in parallel, then pick the
|
|
336
|
+
// target's own metadata first or fall back to any other orientation.
|
|
337
|
+
const tryOrder = [targetOrientation, ...RENDER_ORIENTATIONS.filter((o) => o !== targetOrientation)];
|
|
338
|
+
const metas = await Promise.all(tryOrder.map((orient) => engine.metadata.artifacts.read(assetId, `${orient}.render`)));
|
|
339
|
+
for (const meta of metas) {
|
|
340
|
+
if (meta.ok)
|
|
341
|
+
return { ...defaults, ...meta.value };
|
|
342
|
+
}
|
|
343
|
+
const recs = await readAnalysisRecommendations(engine, assetId);
|
|
344
|
+
return { ...defaults, ...recs };
|
|
345
|
+
}
|
|
346
|
+
const VALID_SUB_POSITIONS = new Set(["top", "middle", "bottom"]);
|
|
347
|
+
const VALID_OSG_POSITIONS = new Set([
|
|
348
|
+
"top-left", "top-center", "top-right",
|
|
349
|
+
"middle-left", "middle-center", "middle-right",
|
|
350
|
+
"bottom-left", "bottom-center", "bottom-right",
|
|
351
|
+
]);
|
|
352
|
+
async function readAnalysisRecommendations(engine, assetId) {
|
|
353
|
+
const result = await engine.files.read(assetId, ".original.analysis.json");
|
|
354
|
+
if (!result.ok)
|
|
355
|
+
return {};
|
|
356
|
+
try {
|
|
357
|
+
const raw = typeof result.value === "string"
|
|
358
|
+
? result.value
|
|
359
|
+
: new TextDecoder().decode(result.value);
|
|
360
|
+
const analysis = JSON.parse(raw);
|
|
361
|
+
const recs = {};
|
|
362
|
+
const subPos = analysis.recommendedSubtitlePosition;
|
|
363
|
+
if (typeof subPos === "string" && VALID_SUB_POSITIONS.has(subPos)) {
|
|
364
|
+
recs.position = subPos;
|
|
365
|
+
}
|
|
366
|
+
const osgPos = analysis.recommendedOsgPosition;
|
|
367
|
+
if (typeof osgPos === "string" && VALID_OSG_POSITIONS.has(osgPos)) {
|
|
368
|
+
recs.osgPosition = osgPos;
|
|
369
|
+
}
|
|
370
|
+
return recs;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Content fingerprint covering every file in the asset directory. Changes
|
|
377
|
+
// whenever any file is added, removed, resized, or rewritten, so every URL
|
|
378
|
+
// derived from an asset's filePath (including alternate-orientation renders
|
|
379
|
+
// like portrait.mp4 / landscape.mp4) invalidates together. Cheap: no file
|
|
380
|
+
// reads — just hashes the immutable manifest tuples. Partial downloads are
|
|
381
|
+
// excluded: their size grows on every poll while no served URL points at
|
|
382
|
+
// them, and including them made every media URL churn (and the browser
|
|
383
|
+
// refetch) once per poll tick for the whole duration of a download.
|
|
384
|
+
function computeAssetFingerprint(files) {
|
|
385
|
+
const settled = files.filter((f) => !PARTIAL_DOWNLOAD_RE.test(f.name));
|
|
386
|
+
if (settled.length === 0)
|
|
387
|
+
return "";
|
|
388
|
+
const sorted = [...settled].sort((a, b) => a.name.localeCompare(b.name));
|
|
389
|
+
const hash = createHash("sha1");
|
|
390
|
+
for (const f of sorted) {
|
|
391
|
+
hash.update(f.name);
|
|
392
|
+
hash.update("\0");
|
|
393
|
+
hash.update(String(f.sizeBytes ?? 0));
|
|
394
|
+
hash.update("\0");
|
|
395
|
+
hash.update(f.objectHash);
|
|
396
|
+
hash.update("\0");
|
|
397
|
+
}
|
|
398
|
+
return hash.digest("hex").slice(0, 12);
|
|
399
|
+
}
|
|
400
|
+
function renderedOrientationsFor(files, kind) {
|
|
401
|
+
const fileSet = new Set(files.map((f) => f.name));
|
|
402
|
+
if (kind === "final") {
|
|
403
|
+
return RENDER_ORIENTATIONS.filter((o) => fileSet.has(`timeline_${o}.mp4`));
|
|
404
|
+
}
|
|
405
|
+
return RENDER_ORIENTATIONS.filter((o) => fileSet.has(`${o}.mp4`));
|
|
406
|
+
}
|
|
407
|
+
function indexByArtifactId(rows) {
|
|
408
|
+
const map = new Map();
|
|
409
|
+
for (const row of rows)
|
|
410
|
+
map.set(row.artifactId, row);
|
|
411
|
+
return map;
|
|
412
|
+
}
|
|
413
|
+
function groupByArtifactId(rows) {
|
|
414
|
+
const map = new Map();
|
|
415
|
+
for (const row of rows) {
|
|
416
|
+
const list = map.get(row.artifactId);
|
|
417
|
+
if (list)
|
|
418
|
+
list.push(row);
|
|
419
|
+
else
|
|
420
|
+
map.set(row.artifactId, [row]);
|
|
421
|
+
}
|
|
422
|
+
return map;
|
|
423
|
+
}
|
|
424
|
+
export async function buildAssetList(engine, book_id, sort) {
|
|
425
|
+
const assets = engine.artifacts.list({ ...(sort ? { sort } : {}) });
|
|
426
|
+
const pendingAll = engine.jobs.pending.findAll();
|
|
427
|
+
const failuresAll = engine.jobs.failures.findAll();
|
|
428
|
+
const workAll = engine.jobs.artifactWork.list();
|
|
429
|
+
const streamsAll = engine.streams.list();
|
|
430
|
+
const pendingByArtifact = indexByArtifactId(pendingAll.ok ? pendingAll.value : []);
|
|
431
|
+
const failuresByArtifact = indexByArtifactId(failuresAll.ok ? failuresAll.value : []);
|
|
432
|
+
const workByArtifact = indexByArtifactId(workAll.ok ? workAll.value : []);
|
|
433
|
+
const streamsByArtifact = groupByArtifactId(streamsAll.ok ? streamsAll.value : []);
|
|
434
|
+
const tagsByArtifact = readAssetTagSummaries(engine, assets.map((asset) => asset.artifactId));
|
|
435
|
+
const assetRows = await Promise.all(assets.map(async (a) => {
|
|
436
|
+
const [manifestResult, metadata, archiveMetadata, proxyMetadata] = await Promise.all([
|
|
437
|
+
engine.files.manifest(a.artifactId, { includeDotfiles: true }),
|
|
438
|
+
engine.metadata.artifacts.read(a.artifactId, "original"),
|
|
439
|
+
engine.metadata.artifacts.read(a.artifactId, "archive"),
|
|
440
|
+
a.kind === "video"
|
|
441
|
+
? engine.metadata.artifacts.read(a.artifactId, "proxy")
|
|
442
|
+
: Promise.resolve({
|
|
443
|
+
ok: false,
|
|
444
|
+
error: { code: "NOT_FOUND", message: "not a video" },
|
|
445
|
+
}),
|
|
446
|
+
]);
|
|
447
|
+
const lockData = engine.jobs.locks.get(a.path);
|
|
448
|
+
const pendingTask = {
|
|
449
|
+
ok: true,
|
|
450
|
+
value: pendingByArtifact.get(a.artifactId) ?? null,
|
|
451
|
+
};
|
|
452
|
+
const generationError = {
|
|
453
|
+
ok: true,
|
|
454
|
+
value: failuresByArtifact.get(a.artifactId) ?? null,
|
|
455
|
+
};
|
|
456
|
+
const assetWorkRow = {
|
|
457
|
+
ok: true,
|
|
458
|
+
value: workByArtifact.get(a.artifactId) ?? null,
|
|
459
|
+
};
|
|
460
|
+
// If the asset directory disappeared between listAssets and getManifest,
|
|
461
|
+
// surface an empty-but-error placeholder rather than throwing.
|
|
462
|
+
const files = manifestResult.ok ? manifestResult.value.files : [];
|
|
463
|
+
const directories = manifestResult.ok ? manifestResult.value.directories : undefined;
|
|
464
|
+
const mediaFile = findAssetMediaFile(files, a);
|
|
465
|
+
const meta = metadata.ok ? metadata.value : null;
|
|
466
|
+
const streams = streamsByArtifact.get(a.artifactId) ?? [];
|
|
467
|
+
const videoStream = streams.find((stream) => stream.kind === "video" && stream.video);
|
|
468
|
+
const audioStream = streams.find((stream) => stream.kind === "audio" && stream.audio);
|
|
469
|
+
const frameRate = videoStream?.video?.averageFrameRate
|
|
470
|
+
?? videoStream?.video?.nominalFrameRate;
|
|
471
|
+
const streamDuration = videoStream
|
|
472
|
+
? videoStream.durationTicks
|
|
473
|
+
* videoStream.timeBase.numerator
|
|
474
|
+
/ videoStream.timeBase.denominator
|
|
475
|
+
: 0;
|
|
476
|
+
const metadataDuration = Number(meta?.duration);
|
|
477
|
+
const sourceDuration = streamDuration > 0
|
|
478
|
+
? streamDuration
|
|
479
|
+
: Number.isFinite(metadataDuration) ? metadataDuration : 0;
|
|
480
|
+
const sourceProfile = sourceDuration > 0
|
|
481
|
+
? {
|
|
482
|
+
videoDuration: sourceDuration,
|
|
483
|
+
avgFps: frameRate
|
|
484
|
+
? frameRate.numerator / frameRate.denominator
|
|
485
|
+
: null,
|
|
486
|
+
hasAudio: audioStream !== undefined || meta?.hasAudio === true,
|
|
487
|
+
}
|
|
488
|
+
: null;
|
|
489
|
+
const fileNames = new Set(files.map((f) => f.name));
|
|
490
|
+
const hasThumbnail = fileNames.has("original.jpg");
|
|
491
|
+
const renders = renderedOrientationsFor(files, a.kind);
|
|
492
|
+
const hasPartFile = files.some((f) => PARTIAL_DOWNLOAD_RE.test(f.name));
|
|
493
|
+
const frameDirs = buildFrameDirs(directories, book_id, a.artifactId);
|
|
494
|
+
// Spoken-media assets: ship the transcript so the inspector can show a
|
|
495
|
+
// copiable transcript (and offer Transcribe when there is none) without a
|
|
496
|
+
// second round trip. One small sidecar read per eligible asset; word-timed
|
|
497
|
+
// transcripts resolve from the object-hash memo on repeat listings.
|
|
498
|
+
const hasTranscriptSidecar = files.some((file) => file.name === TIMED_TRANSCRIPT_FILE || file.name === PLAIN_TRANSCRIPT_FILE);
|
|
499
|
+
const [transcript, vision_analysis] = await Promise.all([
|
|
500
|
+
hasSpokenAudio(a.kind) && hasTranscriptSidecar
|
|
501
|
+
? readAssetTranscriptSources(engine, a.artifactId, files)
|
|
502
|
+
: Promise.resolve(null),
|
|
503
|
+
isAnalyzableVideo(a.kind) && fileNames.has(VIDEO_ANALYSIS_FILE)
|
|
504
|
+
? readVideoAnalysisSummary(engine, a.artifactId)
|
|
505
|
+
: Promise.resolve(null),
|
|
506
|
+
]);
|
|
507
|
+
const waveform = await readWaveformOrSchedule(engine, a.artifactId, book_id, mediaFile?.name ?? null);
|
|
508
|
+
// Cache-bust media URLs so browsers refetch after trim / re-encode / re-render.
|
|
509
|
+
// For video and image assets, use a content fingerprint covering the whole
|
|
510
|
+
// asset directory: orientation re-renders (portrait/landscape/square.mp4)
|
|
511
|
+
// touch alternate files but not original.mp4, so a primary-mtime cache
|
|
512
|
+
// key would leave alternate-orientation URLs cached and stale.
|
|
513
|
+
const useFingerprint = a.kind === "video" || a.kind === "image";
|
|
514
|
+
let cacheBust = '';
|
|
515
|
+
if (useFingerprint) {
|
|
516
|
+
const fp = computeAssetFingerprint(files);
|
|
517
|
+
if (fp)
|
|
518
|
+
cacheBust = `?v=${fp}`;
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
const mediaHash = mediaFile?.objectHash;
|
|
522
|
+
if (mediaHash)
|
|
523
|
+
cacheBust = `?v=${mediaHash.slice(0, 12)}`;
|
|
524
|
+
}
|
|
525
|
+
// Character assets: free-form folders have no original.* media. Project
|
|
526
|
+
// the first image in the folder so sidebar thumbs and drag-drop keep working.
|
|
527
|
+
let charImage = null;
|
|
528
|
+
if (a.kind === "character") {
|
|
529
|
+
charImage = files.find((f) => /\.(jpg|jpeg|png|webp|gif)$/i.test(f.name)) ?? null;
|
|
530
|
+
}
|
|
531
|
+
const proxyCandidate = a.kind === "video"
|
|
532
|
+
? files.find((file) => file.name === "proxy.mp4")
|
|
533
|
+
: null;
|
|
534
|
+
let proxyFile = null;
|
|
535
|
+
if (proxyCandidate && mediaFile) {
|
|
536
|
+
if (proxyMetadata.ok
|
|
537
|
+
&& videoProxyMetadataMatches(proxyMetadata.value, mediaFile, proxyCandidate)) {
|
|
538
|
+
proxyFile = proxyCandidate;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
const assessmentMatches = proxyMetadata.ok
|
|
542
|
+
&& videoProxyAssessmentMatches(proxyMetadata.value, mediaFile, proxyCandidate);
|
|
543
|
+
const active = assetWorkRow.ok
|
|
544
|
+
&& assetWorkRow.value !== null
|
|
545
|
+
&& (assetWorkRow.value.status === "pending" || assetWorkRow.value.status === "working");
|
|
546
|
+
if (!assessmentMatches && !active && sourceProfile) {
|
|
547
|
+
const valid = await assessStoredVideoProxy(engine, a.artifactId, mediaFile, proxyCandidate, sourceProfile);
|
|
548
|
+
if (valid)
|
|
549
|
+
proxyFile = proxyCandidate;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const media_url = charImage
|
|
554
|
+
? `/files/${book_id}/${a.artifactId}/${charImage.name}${cacheBust}`
|
|
555
|
+
: proxyFile
|
|
556
|
+
? `/files/${book_id}/${a.artifactId}/${proxyFile.name}${cacheBust}`
|
|
557
|
+
: mediaFile
|
|
558
|
+
? `/files/${book_id}/${a.artifactId}/${mediaFile.name}${cacheBust}`
|
|
559
|
+
: null;
|
|
560
|
+
const isImage = a.kind === "image";
|
|
561
|
+
const thumbnail_url = charImage
|
|
562
|
+
? `/files/${book_id}/${a.artifactId}/${charImage.name}${cacheBust}`
|
|
563
|
+
: hasThumbnail
|
|
564
|
+
? `/files/${book_id}/${a.artifactId}/original.jpg${cacheBust}`
|
|
565
|
+
: isImage ? media_url : null;
|
|
566
|
+
// Status derivation lives in videobook-engine (`computeArtifactStatus`). We pass
|
|
567
|
+
// the asset-type-aware primary media name we already computed so the
|
|
568
|
+
// engine doesn't have to redo media-type heuristics.
|
|
569
|
+
const status = computeArtifactStatus({
|
|
570
|
+
kind: a.kind,
|
|
571
|
+
fileNames,
|
|
572
|
+
primaryMediaName: mediaFile?.name ?? null,
|
|
573
|
+
hasOriginalMetadata: metadata.ok,
|
|
574
|
+
hasPartFile,
|
|
575
|
+
lockData,
|
|
576
|
+
pendingTask: pendingTask.ok ? pendingTask.value : null,
|
|
577
|
+
generationError: generationError.ok ? generationError.value : null,
|
|
578
|
+
artifactRow: assetWorkRow.ok && assetWorkRow.value
|
|
579
|
+
? {
|
|
580
|
+
status: assetWorkRow.value.status,
|
|
581
|
+
meta: assetWorkRow.value.meta,
|
|
582
|
+
deadlineAt: assetWorkRow.value.deadlineAt,
|
|
583
|
+
}
|
|
584
|
+
: null,
|
|
585
|
+
});
|
|
586
|
+
if (a.kind === "video" && status === "ready" && !hasThumbnail && mediaFile) {
|
|
587
|
+
const recovery = await engine.metadata.artifacts.read(a.artifactId, "poster-recovery");
|
|
588
|
+
if (!recovery.ok || !recovery.value.failed || recovery.value.sourceHash !== mediaFile.objectHash) {
|
|
589
|
+
void queue.enqueue(`extract-video-poster-${book_id}-${a.artifactId}-${mediaFile.objectHash}`, {
|
|
590
|
+
type: "extract_video_poster",
|
|
591
|
+
bookId: book_id,
|
|
592
|
+
posterArtifactId: a.artifactId,
|
|
593
|
+
sourceFilename: mediaFile.name,
|
|
594
|
+
sourceHash: mediaFile.objectHash,
|
|
595
|
+
}).catch((error) => {
|
|
596
|
+
logger.error("poster-enqueue-failed", { assetId: a.artifactId, error });
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const archiveMeta = archiveMetadata.ok ? archiveMetadata.value : null;
|
|
601
|
+
const analysisInfo = analysisInfoFromMetadata(meta);
|
|
602
|
+
if (analysisInfo && fileNames.has("analysis.json")) {
|
|
603
|
+
const output = await analysisOutputProjection(engine, a.artifactId);
|
|
604
|
+
if (output) {
|
|
605
|
+
analysisInfo.output_json = output.outputJson;
|
|
606
|
+
analysisInfo.output_url =
|
|
607
|
+
`/files/${book_id}/${a.artifactId}/analysis.json`;
|
|
608
|
+
analysisInfo.llm_exchanges = output.llmExchanges;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Backfill duration if missing from metadata (video + audio; probeVideo
|
|
612
|
+
// reads format.duration via ffprobe and works for both).
|
|
613
|
+
let duration = meta?.duration ?? null;
|
|
614
|
+
if (duration === null && mediaFile && DURATION_PROBE_EXT_RE.test(mediaFile.name)) {
|
|
615
|
+
try {
|
|
616
|
+
const probe = await probeVideo(join(a.path, mediaFile.name));
|
|
617
|
+
if (Number.isFinite(probe.duration) && probe.duration > 0) {
|
|
618
|
+
duration = probe.duration;
|
|
619
|
+
// Persist so we don't re-probe next time
|
|
620
|
+
if (metadata.ok) {
|
|
621
|
+
await engine.metadata.artifacts.write(a.artifactId, "original", { ...metadata.value, duration });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch { /* non-fatal */ }
|
|
626
|
+
}
|
|
627
|
+
const audio_info = audioStream?.audio
|
|
628
|
+
? {
|
|
629
|
+
codec: audioStream.codec,
|
|
630
|
+
sample_rate_hz: audioStream.audio.sampleRateHz,
|
|
631
|
+
channels: audioStream.audio.channels,
|
|
632
|
+
channel_layout: audioStream.audio.channelLayout,
|
|
633
|
+
duration_seconds: audioStream.durationTicks
|
|
634
|
+
* audioStream.timeBase.numerator
|
|
635
|
+
/ audioStream.timeBase.denominator,
|
|
636
|
+
}
|
|
637
|
+
: null;
|
|
638
|
+
// Character assets: surface whether an ElevenLabs voice is assigned, so the
|
|
639
|
+
// client can flag voiceless hosts before a Clip Show plan is attempted.
|
|
640
|
+
const has_voice = null;
|
|
641
|
+
// Video/final assets: surface the configured OSG overlay so the client can
|
|
642
|
+
// render its badge on clip cards and timeline slots without a per-asset
|
|
643
|
+
// settings fetch. Bounded to one metadata read per OSG-eligible asset.
|
|
644
|
+
const osg = (a.kind === "video" || a.kind === "final")
|
|
645
|
+
? await readOsgSummary(engine, a.artifactId)
|
|
646
|
+
: null;
|
|
647
|
+
// Read script content from its canonical artifact file.
|
|
648
|
+
let prompt = meta?.prompt ?? null;
|
|
649
|
+
if (!prompt && a.kind === "script" && fileNames.has("original.md")) {
|
|
650
|
+
const md = await engine.files.read(a.artifactId, "original.md");
|
|
651
|
+
if (md.ok)
|
|
652
|
+
prompt = md.value.toString();
|
|
653
|
+
}
|
|
654
|
+
const assetWork = assetWorkRow.ok && assetWorkRow.value
|
|
655
|
+
? {
|
|
656
|
+
status: assetWorkRow.value.status,
|
|
657
|
+
meta: assetWorkRow.value.meta,
|
|
658
|
+
ownerKind: assetWorkRow.value.ownerKind,
|
|
659
|
+
deadlineAt: assetWorkRow.value.deadlineAt,
|
|
660
|
+
}
|
|
661
|
+
: null;
|
|
662
|
+
return {
|
|
663
|
+
id: a.artifactId,
|
|
664
|
+
artifact_id: a.artifactId,
|
|
665
|
+
...(a.label ? { label: a.label } : {}),
|
|
666
|
+
type: a.kind,
|
|
667
|
+
created_at: new Date(a.createdAt).toISOString(),
|
|
668
|
+
path: a.path,
|
|
669
|
+
media_url,
|
|
670
|
+
thumbnail_url,
|
|
671
|
+
object_hash: mediaFile?.objectHash ?? null,
|
|
672
|
+
status,
|
|
673
|
+
assetWork,
|
|
674
|
+
width: meta?.width ?? null,
|
|
675
|
+
height: meta?.height ?? null,
|
|
676
|
+
origin: meta?.origin ?? null,
|
|
677
|
+
prompt,
|
|
678
|
+
renders,
|
|
679
|
+
duration,
|
|
680
|
+
fps: (() => {
|
|
681
|
+
const metadataFps = Number(meta?.fps);
|
|
682
|
+
if (Number.isFinite(metadataFps) && metadataFps > 0)
|
|
683
|
+
return metadataFps;
|
|
684
|
+
if (frameRate && frameRate.denominator > 0) {
|
|
685
|
+
const streamFps = frameRate.numerator / frameRate.denominator;
|
|
686
|
+
return Number.isFinite(streamFps) && streamFps > 0 ? streamFps : null;
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
})(),
|
|
690
|
+
audio_info,
|
|
691
|
+
analysis_info: analysisInfo,
|
|
692
|
+
tags: tagsByArtifact.get(a.artifactId) ?? EMPTY_TAG_SUMMARY,
|
|
693
|
+
transcript,
|
|
694
|
+
vision_analysis,
|
|
695
|
+
waveform,
|
|
696
|
+
frame_dirs: frameDirs,
|
|
697
|
+
archive_id: typeof archiveMeta?.archive_id === "string" ? archiveMeta.archive_id : null,
|
|
698
|
+
archived_at: typeof archiveMeta?.archived_at === "string" ? archiveMeta.archived_at : null,
|
|
699
|
+
seen_at: assetWorkRow.ok && assetWorkRow.value ? assetWorkRow.value.seenAt : null,
|
|
700
|
+
has_voice,
|
|
701
|
+
osg,
|
|
702
|
+
};
|
|
703
|
+
}));
|
|
704
|
+
const notebookRows = engine.notebooks
|
|
705
|
+
.list()
|
|
706
|
+
.map((notebook) => ({
|
|
707
|
+
id: notebook.id,
|
|
708
|
+
artifact_id: notebook.id,
|
|
709
|
+
label: notebook.name,
|
|
710
|
+
type: "notebook",
|
|
711
|
+
created_at: notebook.createdAt,
|
|
712
|
+
path: `notebook:${notebook.id}`,
|
|
713
|
+
media_url: null,
|
|
714
|
+
thumbnail_url: null,
|
|
715
|
+
object_hash: null,
|
|
716
|
+
status: "ready",
|
|
717
|
+
assetWork: null,
|
|
718
|
+
width: null,
|
|
719
|
+
height: null,
|
|
720
|
+
origin: "notebook",
|
|
721
|
+
prompt: typeof notebook.description === "string"
|
|
722
|
+
? notebook.description
|
|
723
|
+
: notebook.name,
|
|
724
|
+
renders: [],
|
|
725
|
+
duration: null,
|
|
726
|
+
fps: null,
|
|
727
|
+
audio_info: null,
|
|
728
|
+
analysis_info: null,
|
|
729
|
+
tags: null,
|
|
730
|
+
transcript: null,
|
|
731
|
+
vision_analysis: null,
|
|
732
|
+
waveform: null,
|
|
733
|
+
frame_dirs: {},
|
|
734
|
+
archive_id: null,
|
|
735
|
+
archived_at: null,
|
|
736
|
+
seen_at: null,
|
|
737
|
+
has_voice: null,
|
|
738
|
+
osg: null,
|
|
739
|
+
}));
|
|
740
|
+
const direction = sort === "oldest" ? 1 : -1;
|
|
741
|
+
return [...assetRows, ...notebookRows].sort((left, right) => direction *
|
|
742
|
+
(Date.parse(left.created_at) - Date.parse(right.created_at)));
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Poll provider-pending tasks: submit queued KIE rows, age out stale tasks,
|
|
746
|
+
* and poll kie/veo/xai/alibaba/fal recordInfo lanes that mark
|
|
747
|
+
* completing + enqueue complete_kie_task. Does NOT process external uploads,
|
|
748
|
+
* auto-render, or auto-transcribe.
|
|
749
|
+
*/
|
|
750
|
+
export async function pollPendingProviderTasks(engine, book_id, options = {}) {
|
|
751
|
+
if (currentBookContext()?.operation !== "poll_pending_provider_tasks") {
|
|
752
|
+
return withBookContext({ bookId: book_id, engine, operation: "poll_pending_provider_tasks" }, () => pollPendingProviderTasks(engine, book_id, options));
|
|
753
|
+
}
|
|
754
|
+
await settleFinishedUsage(engine);
|
|
755
|
+
const result = {
|
|
756
|
+
pending: [],
|
|
757
|
+
completed: [],
|
|
758
|
+
failed: [],
|
|
759
|
+
};
|
|
760
|
+
if (options.signal?.aborted)
|
|
761
|
+
return result;
|
|
762
|
+
const tasksRes = await engine.jobs.pending.findAll();
|
|
763
|
+
const tasks = tasksRes.ok ? tasksRes.value : [];
|
|
764
|
+
if (tasks.length === 0)
|
|
765
|
+
return result;
|
|
766
|
+
const kieBaseURL = options.kieBaseURL ?? providerEndpoints().kieBaseURL;
|
|
767
|
+
const falQueueBaseURL = options.falQueueBaseURL ?? providerEndpoints().falQueueBaseURL;
|
|
768
|
+
// Age out any non-completing task older than timeout so it
|
|
769
|
+
// stops showing as pending forever. Completing tasks are skipped because the
|
|
770
|
+
// completion job is already dispatched; recoverAssetsTable handles that lane
|
|
771
|
+
// on server restart by clearing the completing flag and refreshing owner_id.
|
|
772
|
+
const timeoutMs = options.timeoutMs ?? PENDING_TASK_TIMEOUT_MS;
|
|
773
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
774
|
+
const activeTasks = [];
|
|
775
|
+
const expiredTasks = [];
|
|
776
|
+
for (const originalTask of tasks) {
|
|
777
|
+
let t = originalTask;
|
|
778
|
+
if (isQueuedKiePendingTask(t)) {
|
|
779
|
+
try {
|
|
780
|
+
t = await withBookContext({ bookId: book_id, engine, operation: "submit_queued_kie_task", artifactId: t.artifactId }, () => submitQueuedKieTask(engine, t));
|
|
781
|
+
}
|
|
782
|
+
catch (e) {
|
|
783
|
+
const failMsg = e instanceof Error ? e.message : "Queued KIE submit failed";
|
|
784
|
+
const prompt = metaPrompt(t.meta);
|
|
785
|
+
result.failed.push({ assetId: t.artifactId, taskType: t.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
786
|
+
await failPendingTaskAndLog(engine, t.artifactId, t.taskId, { message: failMsg, prompt });
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const ageMs = nowMs - t.createdAt;
|
|
791
|
+
if (t.completing !== true && Number.isFinite(ageMs) && ageMs > timeoutMs) {
|
|
792
|
+
expiredTasks.push(t);
|
|
793
|
+
}
|
|
794
|
+
activeTasks.push(t);
|
|
795
|
+
}
|
|
796
|
+
// Completing tasks — reconciliation already enqueued a completion job for these on a
|
|
797
|
+
// prior poll. Skip polling + re-enqueueing so duplicate completion jobs
|
|
798
|
+
// don't race on the asset-directory lock. Report as pending with state
|
|
799
|
+
// "success" so the inbox tile shows the DOWNLOAD/PROCESS phase.
|
|
800
|
+
const completingTasks = activeTasks.filter((t) => t.completing === true);
|
|
801
|
+
for (const t of completingTasks) {
|
|
802
|
+
result.pending.push({ assetId: t.artifactId, taskType: t.taskType, progress: 100, state: "success", prompt: metaPrompt(t.meta), startedAt: t.createdAt });
|
|
803
|
+
}
|
|
804
|
+
const pollableTasks = activeTasks.filter((t) => t.completing !== true);
|
|
805
|
+
if (options.signal?.aborted)
|
|
806
|
+
return result;
|
|
807
|
+
await Promise.all(pollableTasks.map(async (task) => {
|
|
808
|
+
const ownerId = await engine.jobs.pending.getOwner(task.artifactId, task.taskId);
|
|
809
|
+
if (ownerId) {
|
|
810
|
+
await engine.jobs.artifactWork.renew(task.artifactId, ownerId, 30 * 60_000);
|
|
811
|
+
}
|
|
812
|
+
}));
|
|
813
|
+
// Poll KIE tasks (only if API key is set)
|
|
814
|
+
const kieTasks = pollableTasks.filter(isKiePendingTask);
|
|
815
|
+
const kieApiKey = getApiKey("KIE_API_KEY");
|
|
816
|
+
if (kieApiKey && kieTasks.length > 0) {
|
|
817
|
+
const provider = createKie({
|
|
818
|
+
apiKey: kieApiKey,
|
|
819
|
+
timeout: 30_000,
|
|
820
|
+
...(kieBaseURL ? { baseURL: kieBaseURL } : {}),
|
|
821
|
+
});
|
|
822
|
+
await Promise.all(kieTasks.map(async (task) => {
|
|
823
|
+
try {
|
|
824
|
+
const taskInfo = await provider.get.api.v1.jobs.recordInfo(task.taskId);
|
|
825
|
+
await processKieTaskResult(engine, task, taskInfo, result, book_id);
|
|
826
|
+
}
|
|
827
|
+
catch (e) {
|
|
828
|
+
const failMsg = e instanceof Error ? e.message : "Poll failed";
|
|
829
|
+
const prompt = metaPrompt(task.meta);
|
|
830
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
831
|
+
if (e instanceof KieError) {
|
|
832
|
+
// TS narrowing on `e` is unreliable here, use an explicit assertion.
|
|
833
|
+
const kieErr = e;
|
|
834
|
+
if (kieErr.status >= 400 && kieErr.status < 500) {
|
|
835
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
// Poll KIE Veo tasks (separate namespace: kie.veo.get.api.v1.veo.recordInfo)
|
|
842
|
+
const veoTasks = pollableTasks.filter(isVeoPendingTask);
|
|
843
|
+
if (kieApiKey && veoTasks.length > 0) {
|
|
844
|
+
const provider = createKie({
|
|
845
|
+
apiKey: kieApiKey,
|
|
846
|
+
timeout: 30_000,
|
|
847
|
+
...(kieBaseURL ? { baseURL: kieBaseURL } : {}),
|
|
848
|
+
});
|
|
849
|
+
await Promise.all(veoTasks.map(async (task) => {
|
|
850
|
+
const prompt = metaPrompt(task.meta);
|
|
851
|
+
try {
|
|
852
|
+
const recordInfo = await provider.veo.get.api.v1.veo.recordInfo(task.taskId);
|
|
853
|
+
const data = recordInfo.data;
|
|
854
|
+
// Veo successFlag: 0=submitted/pending, 1=success, 2=fail, 3=processing
|
|
855
|
+
if (data?.successFlag === 1) {
|
|
856
|
+
const urls = data.response?.resultUrls ?? [];
|
|
857
|
+
if (urls.length === 0) {
|
|
858
|
+
const failMsg = "Veo task succeeded but returned no resultUrls";
|
|
859
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
860
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const assetDirResult = await engine.workspaces.resolveArtifact(task.artifactId);
|
|
864
|
+
if (!assetDirResult.ok) {
|
|
865
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: `Asset not found: ${task.artifactId}`, prompt });
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
await engine.jobs.pending.markCompleting(task.artifactId);
|
|
869
|
+
const jobId = `complete-veo-${task.artifactId}-${Date.now()}`;
|
|
870
|
+
await queue.enqueue(jobId, {
|
|
871
|
+
type: "complete_kie_task",
|
|
872
|
+
taskId: task.taskId,
|
|
873
|
+
taskType: task.taskType,
|
|
874
|
+
assetId: task.artifactId,
|
|
875
|
+
assetDir: assetDirResult.value,
|
|
876
|
+
bookId: book_id,
|
|
877
|
+
meta: task.meta,
|
|
878
|
+
resultUrls: urls,
|
|
879
|
+
});
|
|
880
|
+
result.completed.push({ assetId: task.artifactId, taskType: task.taskType, jobId });
|
|
881
|
+
}
|
|
882
|
+
else if (data?.successFlag === 2) {
|
|
883
|
+
const failMsg = data.errorMessage ?? "Veo task failed";
|
|
884
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
885
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
result.pending.push({
|
|
889
|
+
assetId: task.artifactId,
|
|
890
|
+
taskType: task.taskType,
|
|
891
|
+
progress: 0,
|
|
892
|
+
state: data?.successFlag === 3 ? "processing" : "waiting",
|
|
893
|
+
prompt,
|
|
894
|
+
startedAt: task.createdAt,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch (e) {
|
|
899
|
+
const failMsg = e instanceof Error ? e.message : "Veo poll failed";
|
|
900
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
901
|
+
if (e instanceof KieError) {
|
|
902
|
+
const kieErr = e;
|
|
903
|
+
if (kieErr.status >= 400 && kieErr.status < 500) {
|
|
904
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}));
|
|
909
|
+
}
|
|
910
|
+
// Poll xAI tasks (only if API key is set)
|
|
911
|
+
const xaiTasks = pollableTasks.filter((t) => t.taskType.startsWith("xai_"));
|
|
912
|
+
if (process.env.XAI_API_KEY && xaiTasks.length > 0) {
|
|
913
|
+
const provider = createXai({ apiKey: process.env.XAI_API_KEY, timeout: 30_000 });
|
|
914
|
+
await Promise.all(xaiTasks.map(async (task) => {
|
|
915
|
+
const prompt = metaPrompt(task.meta);
|
|
916
|
+
try {
|
|
917
|
+
const videoResult = await provider.get.v1.videos(task.taskId);
|
|
918
|
+
if (videoResult.status === "done" && videoResult.video?.url) {
|
|
919
|
+
const assetDirResult = await engine.workspaces.resolveArtifact(task.artifactId);
|
|
920
|
+
if (!assetDirResult.ok) {
|
|
921
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: `Asset not found: ${task.artifactId}`, prompt });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
await engine.jobs.pending.markCompleting(task.artifactId);
|
|
925
|
+
const jobId = `complete-xai-${task.artifactId}-${Date.now()}`;
|
|
926
|
+
await queue.enqueue(jobId, {
|
|
927
|
+
type: "complete_kie_task",
|
|
928
|
+
taskId: task.taskId,
|
|
929
|
+
taskType: task.taskType,
|
|
930
|
+
assetId: task.artifactId,
|
|
931
|
+
assetDir: assetDirResult.value,
|
|
932
|
+
bookId: book_id,
|
|
933
|
+
meta: task.meta,
|
|
934
|
+
resultUrls: [videoResult.video.url],
|
|
935
|
+
});
|
|
936
|
+
result.completed.push({ assetId: task.artifactId, taskType: task.taskType, jobId });
|
|
937
|
+
}
|
|
938
|
+
else if (videoResult.status === "failed" || videoResult.status === "expired") {
|
|
939
|
+
const failMsg = `xAI task ${videoResult.status}`;
|
|
940
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
941
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
result.pending.push({
|
|
945
|
+
assetId: task.artifactId,
|
|
946
|
+
taskType: task.taskType,
|
|
947
|
+
progress: videoResult.progress ?? 0,
|
|
948
|
+
state: videoResult.status,
|
|
949
|
+
prompt,
|
|
950
|
+
startedAt: task.createdAt,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch (e) {
|
|
955
|
+
const failMsg = e instanceof Error ? e.message : "Poll failed";
|
|
956
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
957
|
+
if (e instanceof XaiError) {
|
|
958
|
+
const xaiErr = e;
|
|
959
|
+
if (xaiErr.status >= 400 && xaiErr.status < 500) {
|
|
960
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}));
|
|
965
|
+
}
|
|
966
|
+
// Poll Alibaba tasks (only if API key is set)
|
|
967
|
+
const alibabaTasks = pollableTasks.filter((t) => t.taskType.startsWith("alibaba_"));
|
|
968
|
+
if (process.env.DASHSCOPE_API_KEY && alibabaTasks.length > 0) {
|
|
969
|
+
const provider = createAlibaba({ apiKey: process.env.DASHSCOPE_API_KEY, timeout: 30_000 });
|
|
970
|
+
await Promise.all(alibabaTasks.map(async (task) => {
|
|
971
|
+
const prompt = metaPrompt(task.meta);
|
|
972
|
+
try {
|
|
973
|
+
const taskStatus = await provider.get.api.v1.tasks(task.taskId);
|
|
974
|
+
const status = taskStatus.output.task_status;
|
|
975
|
+
if (status === "SUCCEEDED") {
|
|
976
|
+
const assetDirResult = await engine.workspaces.resolveArtifact(task.artifactId);
|
|
977
|
+
if (!assetDirResult.ok) {
|
|
978
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: `Asset not found: ${task.artifactId}`, prompt });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
await engine.jobs.pending.markCompleting(task.artifactId);
|
|
982
|
+
const jobId = `complete-alibaba-${task.artifactId}-${Date.now()}`;
|
|
983
|
+
// Video result has video_url, image result has choices with image URLs
|
|
984
|
+
const resultUrls = [];
|
|
985
|
+
if (taskStatus.output.video_url) {
|
|
986
|
+
resultUrls.push(taskStatus.output.video_url);
|
|
987
|
+
}
|
|
988
|
+
if (taskStatus.output.choices) {
|
|
989
|
+
for (const choice of taskStatus.output.choices) {
|
|
990
|
+
for (const content of choice.message.content) {
|
|
991
|
+
if (content.type === "image" && content.image) {
|
|
992
|
+
resultUrls.push(content.image);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
await queue.enqueue(jobId, {
|
|
998
|
+
type: "complete_kie_task",
|
|
999
|
+
taskId: task.taskId,
|
|
1000
|
+
taskType: task.taskType,
|
|
1001
|
+
assetId: task.artifactId,
|
|
1002
|
+
assetDir: assetDirResult.value,
|
|
1003
|
+
bookId: book_id,
|
|
1004
|
+
meta: task.meta,
|
|
1005
|
+
resultUrls,
|
|
1006
|
+
});
|
|
1007
|
+
result.completed.push({ assetId: task.artifactId, taskType: task.taskType, jobId });
|
|
1008
|
+
}
|
|
1009
|
+
else if (status === "FAILED" || status === "CANCELED") {
|
|
1010
|
+
const failMsg = taskStatus.output.message ?? `Alibaba task ${status}`;
|
|
1011
|
+
const code = taskStatus.output.code;
|
|
1012
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
1013
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, {
|
|
1014
|
+
message: failMsg,
|
|
1015
|
+
prompt,
|
|
1016
|
+
...(code !== undefined ? { failCode: code } : {}),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
result.pending.push({
|
|
1021
|
+
assetId: task.artifactId,
|
|
1022
|
+
taskType: task.taskType,
|
|
1023
|
+
progress: 0,
|
|
1024
|
+
state: status.toLowerCase(),
|
|
1025
|
+
prompt,
|
|
1026
|
+
startedAt: task.createdAt,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
const failMsg = e instanceof Error ? e.message : "Poll failed";
|
|
1032
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
1033
|
+
if (e instanceof AlibabaError) {
|
|
1034
|
+
const alibabaErr = e;
|
|
1035
|
+
if (alibabaErr.status >= 400 && alibabaErr.status < 500) {
|
|
1036
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}));
|
|
1041
|
+
}
|
|
1042
|
+
// Poll FAL tasks (only if API key is set)
|
|
1043
|
+
const falTasks = pollableTasks.filter((t) => t.taskType.startsWith("fal_"));
|
|
1044
|
+
if (process.env.FAL_API_KEY && falTasks.length > 0) {
|
|
1045
|
+
const provider = createFal({ apiKey: process.env.FAL_API_KEY, timeout: 30_000, ...(falQueueBaseURL ? { queueBaseURL: falQueueBaseURL } : {}) });
|
|
1046
|
+
// FAL's queue status/result endpoints live under the app namespace
|
|
1047
|
+
// (first 2 path segments). Hitting them with the full nested endpoint id
|
|
1048
|
+
// — e.g. `fal-ai/elevenlabs/speech-to-text/scribe-v2` — 405s. Strip back
|
|
1049
|
+
// to `fal-ai/elevenlabs` for polling.
|
|
1050
|
+
const falNamespace = (endpointId) => endpointId.split("/").slice(0, 2).join("/");
|
|
1051
|
+
await Promise.all(falTasks.map(async (task) => {
|
|
1052
|
+
const prompt = metaPrompt(task.meta);
|
|
1053
|
+
const endpointId = typeof task.meta?.falEndpointId === "string" ? task.meta.falEndpointId : null;
|
|
1054
|
+
if (!endpointId) {
|
|
1055
|
+
const failMsg = `FAL task missing falEndpointId in meta`;
|
|
1056
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
1057
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const statusEndpointId = falNamespace(endpointId);
|
|
1061
|
+
try {
|
|
1062
|
+
const status = await provider.v1.queue.status({ endpoint_id: statusEndpointId, request_id: task.taskId });
|
|
1063
|
+
if (status.status === "COMPLETED") {
|
|
1064
|
+
if (status.error) {
|
|
1065
|
+
const failMsg = status.error;
|
|
1066
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
1067
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const assetDirResult = await engine.workspaces.resolveArtifact(task.artifactId);
|
|
1071
|
+
if (!assetDirResult.ok) {
|
|
1072
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: `Asset not found: ${task.artifactId}`, prompt });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const resultObject = await provider.v1.queue.result({ endpoint_id: statusEndpointId, request_id: task.taskId });
|
|
1076
|
+
// Mark the pending task as completing BEFORE enqueueing so any
|
|
1077
|
+
// overlapping reconciliation poll skips it instead of enqueueing a duplicate.
|
|
1078
|
+
await engine.jobs.pending.markCompleting(task.artifactId);
|
|
1079
|
+
const jobId = `complete-fal-${task.artifactId}-${Date.now()}`;
|
|
1080
|
+
await queue.enqueue(jobId, {
|
|
1081
|
+
type: "complete_kie_task",
|
|
1082
|
+
taskId: task.taskId,
|
|
1083
|
+
taskType: task.taskType,
|
|
1084
|
+
assetId: task.artifactId,
|
|
1085
|
+
assetDir: assetDirResult.value,
|
|
1086
|
+
bookId: book_id,
|
|
1087
|
+
meta: task.meta,
|
|
1088
|
+
resultUrls: [],
|
|
1089
|
+
resultObject,
|
|
1090
|
+
});
|
|
1091
|
+
result.completed.push({ assetId: task.artifactId, taskType: task.taskType, jobId });
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
result.pending.push({
|
|
1095
|
+
assetId: task.artifactId,
|
|
1096
|
+
taskType: task.taskType,
|
|
1097
|
+
progress: 0,
|
|
1098
|
+
state: status.status.toLowerCase(),
|
|
1099
|
+
prompt,
|
|
1100
|
+
startedAt: task.createdAt,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
catch (e) {
|
|
1105
|
+
const failMsg = describeFalError(e);
|
|
1106
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
1107
|
+
if (e instanceof FalError) {
|
|
1108
|
+
const falErr = e;
|
|
1109
|
+
if (falErr.status >= 400 && falErr.status < 500) {
|
|
1110
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}));
|
|
1115
|
+
}
|
|
1116
|
+
for (const task of expiredTasks) {
|
|
1117
|
+
if (options.signal?.aborted)
|
|
1118
|
+
break;
|
|
1119
|
+
const current = engine.jobs.pending.read(task.artifactId);
|
|
1120
|
+
if (!current.ok || !current.value || current.value.taskId !== task.taskId
|
|
1121
|
+
|| current.value.completing)
|
|
1122
|
+
continue;
|
|
1123
|
+
const ageMinutes = Math.round((nowMs - task.createdAt) / 60_000);
|
|
1124
|
+
const failMsg = `Task timed out after ${ageMinutes}m without reaching success or fail state`;
|
|
1125
|
+
const prompt = metaPrompt(task.meta);
|
|
1126
|
+
result.pending = result.pending.filter((entry) => entry.assetId !== task.artifactId);
|
|
1127
|
+
result.failed = result.failed.filter((entry) => entry.assetId !== task.artifactId);
|
|
1128
|
+
result.failed.push({ assetId: task.artifactId, taskType: task.taskType, failMsg, failedAt: new Date().toISOString(), prompt });
|
|
1129
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: failMsg, prompt });
|
|
1130
|
+
}
|
|
1131
|
+
return result;
|
|
1132
|
+
}
|
|
1133
|
+
export async function reconcileProjectWork(engine, book_id) {
|
|
1134
|
+
const result = {
|
|
1135
|
+
pending: [], completed: [], failed: [],
|
|
1136
|
+
external_uploads: [], auto_transcribe: [], auto_plain_transcript: [],
|
|
1137
|
+
};
|
|
1138
|
+
// Single listAssets call, shared by all consumers
|
|
1139
|
+
const assets = await engine.artifacts.list();
|
|
1140
|
+
// Run discovery steps in parallel with provider polling
|
|
1141
|
+
const [uploads, providerResult, transcribeActions, plainTranscriptActions, failuresRes] = await Promise.all([
|
|
1142
|
+
findUnprocessedUploads(engine, assets),
|
|
1143
|
+
pollPendingProviderTasks(engine, book_id),
|
|
1144
|
+
findAutoTranscribeActions(engine, assets),
|
|
1145
|
+
findAutoPlainTranscriptActions(engine, assets),
|
|
1146
|
+
engine.jobs.failures.findAll(),
|
|
1147
|
+
]);
|
|
1148
|
+
const persistedFailures = failuresRes.ok ? failuresRes.value : [];
|
|
1149
|
+
// Process external uploads
|
|
1150
|
+
for (const upload of uploads) {
|
|
1151
|
+
await processExternalUpload(engine, upload, book_id);
|
|
1152
|
+
result.external_uploads.push({ assetId: upload.assetId, mediaFilename: upload.mediaFilename });
|
|
1153
|
+
}
|
|
1154
|
+
result.pending.push(...providerResult.pending);
|
|
1155
|
+
result.completed.push(...providerResult.completed);
|
|
1156
|
+
result.failed.push(...providerResult.failed);
|
|
1157
|
+
const tasksRes = await engine.jobs.pending.findAll();
|
|
1158
|
+
const tasks = tasksRes.ok ? tasksRes.value : [];
|
|
1159
|
+
// Merge persisted runtime failures that weren't already
|
|
1160
|
+
// reported by this poll and don't have an active pending task (retry in progress).
|
|
1161
|
+
const reportedAssetIds = new Set(result.failed.map((f) => f.assetId));
|
|
1162
|
+
const pendingAssetIds = new Set(tasks.map((t) => t.artifactId));
|
|
1163
|
+
for (const failure of persistedFailures) {
|
|
1164
|
+
if (!reportedAssetIds.has(failure.artifactId) && !pendingAssetIds.has(failure.artifactId)) {
|
|
1165
|
+
const asset = assets.find(a => a.artifactId === failure.artifactId);
|
|
1166
|
+
if (asset) {
|
|
1167
|
+
const manifestResult = await engine.files.manifest(failure.artifactId);
|
|
1168
|
+
if (manifestResult.ok) {
|
|
1169
|
+
const files = manifestResult.value.files;
|
|
1170
|
+
let hasMedia = false;
|
|
1171
|
+
if (asset.kind === "character") {
|
|
1172
|
+
hasMedia = files.some(f => /\\.(jpg|jpeg|png|webp|gif)$/i.test(f.name));
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
hasMedia = findAssetMediaFile(files, asset) !== null;
|
|
1176
|
+
}
|
|
1177
|
+
if (hasMedia) {
|
|
1178
|
+
await engine.jobs.failures.clear(failure.artifactId);
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
result.failed.push({
|
|
1184
|
+
assetId: failure.artifactId,
|
|
1185
|
+
taskType: "",
|
|
1186
|
+
failMsg: failure.message,
|
|
1187
|
+
failedAt: new Date(failure.failedAt).toISOString(),
|
|
1188
|
+
prompt: failure.prompt ?? null,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// Auto-render and auto-analyze are intentionally NOT performed here.
|
|
1193
|
+
// Renders fire from explicit /render or assign_slot (lazy on slot drop).
|
|
1194
|
+
// Gemini analysis fires from assign_slot (lazy on slot drop) and
|
|
1195
|
+
// OpenAI plain speech-to-text fires eagerly from process_video on upload,
|
|
1196
|
+
// with a revision-scoped backfill here for processed videos that missed
|
|
1197
|
+
// .analysis.gpt4otrans.json. Reconciliation still retries ElevenLabs
|
|
1198
|
+
// (auto_transcribe) only after .original.analysis.json exists.
|
|
1199
|
+
// Auto-pipeline: advance analyzed videos through transcription. Stable
|
|
1200
|
+
// dedupe key, shared with analyze-video's auto-transcribe trigger: racing
|
|
1201
|
+
// enqueues collapse onto one queued job instead of transcribing twice.
|
|
1202
|
+
for (const action of transcribeActions) {
|
|
1203
|
+
const jobId = `auto-transcribe-${action.assetId}`;
|
|
1204
|
+
await queue.enqueue(jobId, {
|
|
1205
|
+
type: "transcribe",
|
|
1206
|
+
audioPath: action.audioPath,
|
|
1207
|
+
audioFilename: action.audioFilename,
|
|
1208
|
+
tagAudioEvents: true,
|
|
1209
|
+
diarize: true,
|
|
1210
|
+
assetId: action.assetId,
|
|
1211
|
+
assetDir: action.assetDir,
|
|
1212
|
+
bookId: book_id,
|
|
1213
|
+
});
|
|
1214
|
+
result.auto_transcribe.push({ assetId: action.assetId, jobId });
|
|
1215
|
+
}
|
|
1216
|
+
for (const action of plainTranscriptActions) {
|
|
1217
|
+
const planned = planVideoTranscriptJobs({
|
|
1218
|
+
skipDownstreamJobs: false,
|
|
1219
|
+
hasAudio: true,
|
|
1220
|
+
assetId: action.assetId,
|
|
1221
|
+
assetDir: action.assetDir,
|
|
1222
|
+
bookId: book_id,
|
|
1223
|
+
videoFilename: action.videoFilename,
|
|
1224
|
+
identity: action.revision,
|
|
1225
|
+
hasOpenAiKey: true,
|
|
1226
|
+
hasElevenLabsKey: false,
|
|
1227
|
+
});
|
|
1228
|
+
for (const job of planned) {
|
|
1229
|
+
await queue.enqueue(job.jobId, job.payload);
|
|
1230
|
+
result.auto_plain_transcript.push({ assetId: action.assetId, jobId: job.jobId });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return result;
|
|
1234
|
+
}
|
|
1235
|
+
export function reconcileProjectWorkDef(engine) {
|
|
1236
|
+
return {
|
|
1237
|
+
name: "reconcile_project_work",
|
|
1238
|
+
description: "Finish pending provider tasks for this book and pick up files added outside the app (Finder or CLI) that need processing.",
|
|
1239
|
+
schema: {
|
|
1240
|
+
book_id: z.string().describe("Book id"),
|
|
1241
|
+
},
|
|
1242
|
+
handler: async (args) => {
|
|
1243
|
+
const { book_id } = args;
|
|
1244
|
+
const reconcileResult = await reconcileProjectWork(engine, book_id);
|
|
1245
|
+
return {
|
|
1246
|
+
content: [{ type: "text", text: JSON.stringify(reconcileResult) }],
|
|
1247
|
+
};
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
async function processKieTaskResult(engine, task, taskInfo, result, bookId) {
|
|
1252
|
+
const data = taskInfo.data;
|
|
1253
|
+
const prompt = metaPrompt(task.meta);
|
|
1254
|
+
if (data?.state === "success") {
|
|
1255
|
+
const assetDirResult = await engine.workspaces.resolveArtifact(task.artifactId);
|
|
1256
|
+
if (!assetDirResult.ok) {
|
|
1257
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, { message: `Asset not found: ${task.artifactId}`, prompt });
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const jobId = `complete-${task.taskType}-${task.artifactId}-${Date.now()}`;
|
|
1261
|
+
const parsed = JSON.parse(data.resultJson ?? "{}");
|
|
1262
|
+
// Mark the pending task as completing BEFORE enqueueing so any
|
|
1263
|
+
// overlapping reconciliation poll skips it instead of enqueueing a duplicate.
|
|
1264
|
+
await engine.jobs.pending.markCompleting(task.artifactId);
|
|
1265
|
+
await queue.enqueue(jobId, {
|
|
1266
|
+
type: "complete_kie_task",
|
|
1267
|
+
taskId: task.taskId,
|
|
1268
|
+
taskType: task.taskType,
|
|
1269
|
+
assetId: task.artifactId,
|
|
1270
|
+
assetDir: assetDirResult.value,
|
|
1271
|
+
bookId,
|
|
1272
|
+
meta: task.meta,
|
|
1273
|
+
resultUrls: parsed.resultUrls ?? [],
|
|
1274
|
+
resultObject: parsed.resultObject,
|
|
1275
|
+
});
|
|
1276
|
+
result.completed.push({ assetId: task.artifactId, taskType: task.taskType, jobId });
|
|
1277
|
+
}
|
|
1278
|
+
else if (data?.state === "fail") {
|
|
1279
|
+
const failMsg = data.failMsg ?? "unknown error";
|
|
1280
|
+
// A kie 500 is only retryable while the task is still in flight; once it
|
|
1281
|
+
// has terminally completed, surface the failure instead of stranding the
|
|
1282
|
+
// asset as "working" until the 30m reconciliation timeout. See isTransientKieFailure.
|
|
1283
|
+
if (isTransientKieFailure(data)) {
|
|
1284
|
+
result.pending.push({
|
|
1285
|
+
assetId: task.artifactId,
|
|
1286
|
+
taskType: task.taskType,
|
|
1287
|
+
progress: data.progress ?? 0,
|
|
1288
|
+
state: data.state ?? "waiting",
|
|
1289
|
+
prompt,
|
|
1290
|
+
startedAt: task.createdAt,
|
|
1291
|
+
});
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
result.failed.push({
|
|
1295
|
+
assetId: task.artifactId,
|
|
1296
|
+
taskType: task.taskType,
|
|
1297
|
+
failMsg,
|
|
1298
|
+
failedAt: new Date().toISOString(),
|
|
1299
|
+
prompt,
|
|
1300
|
+
});
|
|
1301
|
+
await failPendingTaskAndLog(engine, task.artifactId, task.taskId, {
|
|
1302
|
+
message: failMsg,
|
|
1303
|
+
prompt,
|
|
1304
|
+
...(data.failCode !== undefined ? { failCode: data.failCode } : {}),
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
result.pending.push({
|
|
1309
|
+
assetId: task.artifactId,
|
|
1310
|
+
taskType: task.taskType,
|
|
1311
|
+
progress: data?.progress ?? 0,
|
|
1312
|
+
state: data?.state ?? "waiting",
|
|
1313
|
+
prompt,
|
|
1314
|
+
startedAt: task.createdAt,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
export async function findEligibleRenderActions(engine, assets, orientation) {
|
|
1319
|
+
const candidates = await Promise.all(assets.filter((a) => a.kind === "video").map(async (asset) => {
|
|
1320
|
+
const manifestResult = await engine.files.manifest(asset.artifactId);
|
|
1321
|
+
if (!manifestResult.ok)
|
|
1322
|
+
return null;
|
|
1323
|
+
const manifest = manifestResult.value;
|
|
1324
|
+
const files = manifest.files;
|
|
1325
|
+
const fileNames = new Set(files.map((f) => f.name));
|
|
1326
|
+
const [analysis, transcription] = await Promise.all([
|
|
1327
|
+
engine.files.read(asset.artifactId, ".original.analysis.json"),
|
|
1328
|
+
engine.files.read(asset.artifactId, ".analysis.gpt4otrans.json"),
|
|
1329
|
+
]);
|
|
1330
|
+
// Skip: already rendered for target orientation
|
|
1331
|
+
const renderMetadata = await engine.metadata.artifacts.read(asset.artifactId, `${orientation}.render`);
|
|
1332
|
+
if (renderMetadata.ok && typeof renderMetadata.value.renderedAt === "string")
|
|
1333
|
+
return null;
|
|
1334
|
+
// Skip: process_video incomplete
|
|
1335
|
+
if (!analysis.ok)
|
|
1336
|
+
return null;
|
|
1337
|
+
// Skip if speech-to-text analysis not yet complete (unless video has no audio)
|
|
1338
|
+
if (!transcription.ok) {
|
|
1339
|
+
const meta = await engine.metadata.artifacts.read(asset.artifactId, "original");
|
|
1340
|
+
if (meta.ok && meta.value.hasAudio)
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
// For videos with dialog, wait for ElevenLabs transcription
|
|
1344
|
+
const dialogQuality = await readAndAssessDialog(engine, asset.artifactId);
|
|
1345
|
+
if (dialogQuality.hasDialog && !fileNames.has("elevenlabs.json"))
|
|
1346
|
+
return null;
|
|
1347
|
+
const videoFile = findVideoFile(files);
|
|
1348
|
+
if (videoFile && !await engine.jobs.locks.isLocked(manifest.path)) {
|
|
1349
|
+
return {
|
|
1350
|
+
assetId: asset.artifactId,
|
|
1351
|
+
assetDir: manifest.path,
|
|
1352
|
+
videoPath: join(manifest.path, videoFile.name),
|
|
1353
|
+
videoFilename: videoFile.name,
|
|
1354
|
+
hasElevenlabs: fileNames.has("elevenlabs.json"),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
}));
|
|
1359
|
+
return candidates.filter((c) => c !== null);
|
|
1360
|
+
}
|
|
1361
|
+
async function findAutoTranscribeActions(engine, assets) {
|
|
1362
|
+
if (!process.env.ELEVENLABS_API_KEY)
|
|
1363
|
+
return [];
|
|
1364
|
+
const candidates = await Promise.all(assets.filter((a) => a.kind === "video").map(async (asset) => {
|
|
1365
|
+
const [manifestResult, locked, errorRow] = await Promise.all([
|
|
1366
|
+
engine.files.manifest(asset.artifactId),
|
|
1367
|
+
engine.jobs.locks.isLocked(asset.path),
|
|
1368
|
+
engine.jobs.failures.read(asset.artifactId),
|
|
1369
|
+
]);
|
|
1370
|
+
if (!manifestResult.ok)
|
|
1371
|
+
return null;
|
|
1372
|
+
const manifest = manifestResult.value;
|
|
1373
|
+
const files = manifest.files;
|
|
1374
|
+
const fileNames = new Set(files.map((f) => f.name));
|
|
1375
|
+
// Must have completed analysis
|
|
1376
|
+
if (!(await engine.files.read(asset.artifactId, ".original.analysis.json")).ok)
|
|
1377
|
+
return null;
|
|
1378
|
+
// Skip if already transcribed
|
|
1379
|
+
if (fileNames.has("elevenlabs.json"))
|
|
1380
|
+
return null;
|
|
1381
|
+
// Skip if a prior transcription attempt failed — do not auto-retry.
|
|
1382
|
+
// User must manually clear the error to retry.
|
|
1383
|
+
if (errorRow.ok && errorRow.value !== null)
|
|
1384
|
+
return null;
|
|
1385
|
+
// Skip if locked
|
|
1386
|
+
if (locked)
|
|
1387
|
+
return null;
|
|
1388
|
+
// Must have real dialog
|
|
1389
|
+
const dialogQuality = await readAndAssessDialog(engine, asset.artifactId);
|
|
1390
|
+
if (!dialogQuality.hasDialog)
|
|
1391
|
+
return null;
|
|
1392
|
+
// Must have an audio file
|
|
1393
|
+
const audioFile = findAudioFile(files, asset.kind);
|
|
1394
|
+
if (!audioFile)
|
|
1395
|
+
return null;
|
|
1396
|
+
return {
|
|
1397
|
+
assetId: asset.artifactId,
|
|
1398
|
+
assetDir: manifest.path,
|
|
1399
|
+
audioPath: join(manifest.path, audioFile.name),
|
|
1400
|
+
audioFilename: audioFile.name,
|
|
1401
|
+
};
|
|
1402
|
+
}));
|
|
1403
|
+
return candidates.filter((c) => c !== null);
|
|
1404
|
+
}
|
|
1405
|
+
async function findAutoPlainTranscriptActions(engine, assets) {
|
|
1406
|
+
if (!process.env.OPENAI_API_KEY)
|
|
1407
|
+
return [];
|
|
1408
|
+
const candidates = await Promise.all(assets.filter((a) => a.kind === "video").map(async (asset) => {
|
|
1409
|
+
const [manifestResult, locked, errorRow, originalMeta] = await Promise.all([
|
|
1410
|
+
engine.files.manifest(asset.artifactId),
|
|
1411
|
+
engine.jobs.locks.isLocked(asset.path),
|
|
1412
|
+
engine.jobs.failures.read(asset.artifactId),
|
|
1413
|
+
engine.metadata.artifacts.read(asset.artifactId, "original"),
|
|
1414
|
+
]);
|
|
1415
|
+
if (!manifestResult.ok)
|
|
1416
|
+
return null;
|
|
1417
|
+
if (!originalMeta.ok)
|
|
1418
|
+
return null;
|
|
1419
|
+
if (originalMeta.value.hasAudio === false)
|
|
1420
|
+
return null;
|
|
1421
|
+
if (errorRow.ok && errorRow.value !== null)
|
|
1422
|
+
return null;
|
|
1423
|
+
if (locked)
|
|
1424
|
+
return null;
|
|
1425
|
+
const manifest = manifestResult.value;
|
|
1426
|
+
const files = manifest.files;
|
|
1427
|
+
if ((await engine.files.read(asset.artifactId, ".analysis.gpt4otrans.json")).ok) {
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
const audioFile = findAudioFile(files, asset.kind);
|
|
1431
|
+
if (!audioFile)
|
|
1432
|
+
return null;
|
|
1433
|
+
const videoFile = files.find((file) => file.name === "original.mp4")
|
|
1434
|
+
?? findVideoFile(files);
|
|
1435
|
+
if (!videoFile)
|
|
1436
|
+
return null;
|
|
1437
|
+
const revision = videoFile.objectHash;
|
|
1438
|
+
const existing = engine.jobs.queue.list({
|
|
1439
|
+
type: "transcribe_video_audio",
|
|
1440
|
+
artifactId: asset.artifactId,
|
|
1441
|
+
});
|
|
1442
|
+
if (existing.some((job) => job.payload.mediaRevision === revision))
|
|
1443
|
+
return null;
|
|
1444
|
+
return {
|
|
1445
|
+
assetId: asset.artifactId,
|
|
1446
|
+
assetDir: manifest.path,
|
|
1447
|
+
videoFilename: videoFile.name,
|
|
1448
|
+
revision,
|
|
1449
|
+
};
|
|
1450
|
+
}));
|
|
1451
|
+
return candidates.filter((c) => c !== null);
|
|
1452
|
+
}
|