ltcai 11.1.0 → 11.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/README.md +53 -53
- package/docs/CHANGELOG.md +33 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +175 -27
- package/lattice_brain/multimodal.py +525 -5
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +55 -2
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +6 -3
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +143 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +28 -0
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +26 -1
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +13 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
- package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
- package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
- package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
- package/static/app/assets/brain-uMb_5hnO.js +0 -1
- package/static/app/assets/search-DkhnOKZt.js +0 -1
|
@@ -41,6 +41,9 @@ import base64
|
|
|
41
41
|
import hashlib
|
|
42
42
|
import io
|
|
43
43
|
import mimetypes
|
|
44
|
+
import re
|
|
45
|
+
import shutil
|
|
46
|
+
import subprocess # noqa: S404 — one fixed binary, argv list, never a shell
|
|
44
47
|
from dataclasses import dataclass, field
|
|
45
48
|
from pathlib import Path
|
|
46
49
|
from typing import Any, Callable, Dict, List, Optional
|
|
@@ -70,12 +73,21 @@ AUDIO_EXTENSIONS = frozenset(
|
|
|
70
73
|
{".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg", ".opus", ".mid", ".midi"}
|
|
71
74
|
)
|
|
72
75
|
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"})
|
|
73
|
-
|
|
74
|
-
#:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
#: Subtitle/caption files a video may arrive with. Same basename, so a
|
|
77
|
+
#: ``standup.mp4`` next to a ``standup.srt`` is one memory, not two.
|
|
78
|
+
SUBTITLE_EXTENSIONS = ("srt", "vtt")
|
|
79
|
+
|
|
80
|
+
#: Why a video is recognized and still refused — surfaced to the caller. In
|
|
81
|
+
#: 11.1.0 the reason was *scope* (nothing was implemented). Since 11.2.0 the
|
|
82
|
+
#: implementation exists and the only remaining reason is a **runtime** one:
|
|
83
|
+
#: this machine has no ``ffmpeg``, and inventing frames is not an option.
|
|
84
|
+
VIDEO_UNAVAILABLE_DETAIL = (
|
|
85
|
+
"video ingestion needs ffmpeg on this machine and none was found; the file "
|
|
86
|
+
"was not stored (install ffmpeg to enable keyframe extraction)"
|
|
78
87
|
)
|
|
88
|
+
#: Kept under its 11.1.0 name so existing importers keep working; the reason it
|
|
89
|
+
#: carries has changed from "out of scope" to "unavailable on this machine".
|
|
90
|
+
VIDEO_OUT_OF_SCOPE = VIDEO_UNAVAILABLE_DETAIL
|
|
79
91
|
|
|
80
92
|
#: Longest OCR/caption body kept on the node (a screenshot is not a novel).
|
|
81
93
|
MAX_INDEX_TEXT_CHARS = 20_000
|
|
@@ -138,6 +150,13 @@ class MultimodalPorts:
|
|
|
138
150
|
vision_embedder: Optional[Callable[[str], List[float]]] = None
|
|
139
151
|
#: ``(audio_path) -> transcript`` (raises/returns empty when it cannot).
|
|
140
152
|
transcriber: Optional[Callable[[str], str]] = None
|
|
153
|
+
#: ``(video_path, dest_dir, count) -> [frame paths]`` (v11.2.0). ``None``
|
|
154
|
+
#: falls back to ffmpeg on PATH; absent ffmpeg is reported, never faked.
|
|
155
|
+
keyframe_extractor: Optional[Callable[..., Any]] = None
|
|
156
|
+
#: ``(query_text) -> vector`` in the *image* space (v11.2.0). Only a
|
|
157
|
+
#: genuinely shared-space vision model can supply one, which is why it is
|
|
158
|
+
#: its own port instead of being assumed from ``vision_embedder``.
|
|
159
|
+
text_to_image_embedder: Optional[Callable[[str], List[float]]] = None
|
|
141
160
|
#: Identity of the vision model, recorded next to every image vector.
|
|
142
161
|
vision_model_id: str = ""
|
|
143
162
|
#: ``image`` (own index + late fusion) or ``shared`` (same space as text).
|
|
@@ -149,6 +168,8 @@ class MultimodalPorts:
|
|
|
149
168
|
"caption": self.captioner is not None,
|
|
150
169
|
"vision_embedding": self.vision_embedder is not None,
|
|
151
170
|
"transcription": self.transcriber is not None,
|
|
171
|
+
"keyframes": self.keyframe_extractor is not None or ffmpeg_available(),
|
|
172
|
+
"text_to_image_query": self.text_to_image_embedder is not None,
|
|
152
173
|
"vision_model_id": self.vision_model_id,
|
|
153
174
|
"vision_space": self.vision_space,
|
|
154
175
|
}
|
|
@@ -711,28 +732,527 @@ def audio_quality_score(facts: AudioFacts) -> Dict[str, Any]:
|
|
|
711
732
|
return {"score": round(score, 4), "reasons": ["transcript"]}
|
|
712
733
|
|
|
713
734
|
|
|
735
|
+
# ── video (v11.2.0) ──────────────────────────────────────────────────────────
|
|
736
|
+
#: The decoder. Looked up by name on PATH and never bundled — a product that
|
|
737
|
+
#: cannot decode a ``.mov`` says so instead of shipping a codec pack.
|
|
738
|
+
FFMPEG_BINARY = "ffmpeg"
|
|
739
|
+
#: Keyframes kept per video. Four is a memory of a video, not a copy of it.
|
|
740
|
+
DEFAULT_KEYFRAMES = 4
|
|
741
|
+
#: Frames ffmpeg's ``thumbnail`` filter considers before picking one. Larger
|
|
742
|
+
#: windows mean more representative frames and a slower pass.
|
|
743
|
+
KEYFRAME_WINDOW = 300
|
|
744
|
+
KEYFRAME_TIMEOUT_SECONDS = 120
|
|
745
|
+
#: Graph node type for a video. ``NodeType.VIDEO`` normalizes this on the KG v2
|
|
746
|
+
#: write side; the legacy tables keep the label verbatim, like ``Audio``.
|
|
747
|
+
VIDEO_NODE_TYPE = "Video"
|
|
748
|
+
#: Source type stamped on the extracted stills so a keyframe is never mistaken
|
|
749
|
+
#: for a photograph the user took.
|
|
750
|
+
VIDEO_FRAME_SOURCE_TYPE = "video_keyframe"
|
|
751
|
+
VIDEO_FRAME_RELATION = "CONTAINS_IMAGE"
|
|
752
|
+
#: Longest subtitle body kept, matching the OCR/caption ceiling.
|
|
753
|
+
MAX_SUBTITLE_CHARS = MAX_INDEX_TEXT_CHARS
|
|
754
|
+
|
|
755
|
+
_SRT_INDEX_RE = re.compile(r"^\d+$")
|
|
756
|
+
_TIMECODE_RE = re.compile(r"\d{1,2}:\d{2}:\d{2}[.,]\d{1,3}\s*-->")
|
|
757
|
+
_CUE_TAG_RE = re.compile(r"</?[a-zA-Z][^>]*>")
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _which_ffmpeg() -> Optional[str]:
|
|
761
|
+
"""Absolute path to ffmpeg, or ``None``. The one probe, seamed for tests."""
|
|
762
|
+
return shutil.which(FFMPEG_BINARY)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def ffmpeg_available() -> bool:
|
|
766
|
+
"""Whether this machine can decode a video at all (honest, never assumed)."""
|
|
767
|
+
return _which_ffmpeg() is not None
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _run_ffmpeg(binary: str, args: List[str]) -> int:
|
|
771
|
+
"""Run one fixed binary with an argv list — no shell, no user strings."""
|
|
772
|
+
completed = subprocess.run( # noqa: S603 — argv list, fixed binary, no shell
|
|
773
|
+
[binary, *args],
|
|
774
|
+
stdout=subprocess.DEVNULL,
|
|
775
|
+
stderr=subprocess.DEVNULL,
|
|
776
|
+
timeout=KEYFRAME_TIMEOUT_SECONDS,
|
|
777
|
+
check=False,
|
|
778
|
+
)
|
|
779
|
+
return int(completed.returncode)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def find_subtitle(path: Any) -> Optional[Path]:
|
|
783
|
+
"""The ``.srt``/``.vtt`` sitting next to a video under the same basename."""
|
|
784
|
+
video = Path(str(path))
|
|
785
|
+
for suffix in SUBTITLE_EXTENSIONS:
|
|
786
|
+
candidate = video.with_suffix(f".{suffix}")
|
|
787
|
+
if candidate.is_file():
|
|
788
|
+
return candidate
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def parse_subtitles(text: str) -> str:
|
|
793
|
+
"""Strip SRT/WebVTT scaffolding down to the words that were said.
|
|
794
|
+
|
|
795
|
+
Cue numbers, timecodes, ``WEBVTT`` headers, ``NOTE`` blocks and inline
|
|
796
|
+
``<c>``/``<i>`` tags carry no recall value and would otherwise dominate a
|
|
797
|
+
chunk. Consecutive duplicate lines (the usual rolling-caption artefact) are
|
|
798
|
+
collapsed. Deliberately a small parser, not a dependency: the format is two
|
|
799
|
+
rules deep and a library here would sit in the ingest path forever.
|
|
800
|
+
"""
|
|
801
|
+
lines: List[str] = []
|
|
802
|
+
for raw in str(text or "").splitlines():
|
|
803
|
+
line = raw.strip().lstrip("")
|
|
804
|
+
if not line or line.upper().startswith("WEBVTT") or line.startswith("NOTE"):
|
|
805
|
+
continue
|
|
806
|
+
if _SRT_INDEX_RE.match(line) or _TIMECODE_RE.search(line):
|
|
807
|
+
continue
|
|
808
|
+
cleaned = _CUE_TAG_RE.sub("", line).strip()
|
|
809
|
+
if not cleaned:
|
|
810
|
+
continue
|
|
811
|
+
if lines and lines[-1] == cleaned:
|
|
812
|
+
continue
|
|
813
|
+
lines.append(cleaned)
|
|
814
|
+
return "\n".join(lines)[:MAX_SUBTITLE_CHARS]
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@dataclass
|
|
818
|
+
class VideoFacts:
|
|
819
|
+
"""What could actually be observed in one video, and how each attempt went."""
|
|
820
|
+
|
|
821
|
+
path: str
|
|
822
|
+
#: ``ok`` | ``unavailable`` | ``failed`` | ``empty``
|
|
823
|
+
keyframe_status: str = "unavailable"
|
|
824
|
+
keyframes: List[str] = field(default_factory=list)
|
|
825
|
+
keyframe_detail: str = ""
|
|
826
|
+
#: ``ok`` | ``absent`` | ``unreadable`` | ``empty``
|
|
827
|
+
subtitle_status: str = "absent"
|
|
828
|
+
subtitle_path: Optional[str] = None
|
|
829
|
+
subtitle_text: str = ""
|
|
830
|
+
|
|
831
|
+
@property
|
|
832
|
+
def searchable(self) -> bool:
|
|
833
|
+
"""Whether anything in this video can be matched by a typed question."""
|
|
834
|
+
return bool(self.subtitle_text)
|
|
835
|
+
|
|
836
|
+
def as_metadata(self) -> Dict[str, Any]:
|
|
837
|
+
payload: Dict[str, Any] = {
|
|
838
|
+
"modality": MODALITY_VIDEO,
|
|
839
|
+
"video_path": self.path,
|
|
840
|
+
"keyframes": len(self.keyframes),
|
|
841
|
+
"keyframe_status": self.keyframe_status,
|
|
842
|
+
"subtitles": self.subtitle_status,
|
|
843
|
+
"searchable": self.searchable,
|
|
844
|
+
}
|
|
845
|
+
if self.keyframe_detail:
|
|
846
|
+
payload["keyframe_detail"] = self.keyframe_detail
|
|
847
|
+
if self.subtitle_path:
|
|
848
|
+
payload["subtitle_path"] = self.subtitle_path
|
|
849
|
+
return payload
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def extract_keyframes(
|
|
853
|
+
path: Any,
|
|
854
|
+
dest_dir: Any,
|
|
855
|
+
*,
|
|
856
|
+
count: int = DEFAULT_KEYFRAMES,
|
|
857
|
+
ports: Optional[MultimodalPorts] = None,
|
|
858
|
+
) -> Dict[str, Any]:
|
|
859
|
+
"""Pull up to ``count`` representative stills out of a video.
|
|
860
|
+
|
|
861
|
+
An injected ``ports.keyframe_extractor`` wins outright — that is the seam
|
|
862
|
+
an install with its own decoder (or a test) uses. Otherwise ffmpeg's
|
|
863
|
+
``thumbnail`` filter picks the most representative frame from each window
|
|
864
|
+
of :data:`KEYFRAME_WINDOW` frames, which is one pass and no probing.
|
|
865
|
+
|
|
866
|
+
Never raises. A missing decoder, a non-zero exit, and a video too short to
|
|
867
|
+
yield a single frame are three different states and each says so.
|
|
868
|
+
"""
|
|
869
|
+
ports = ports or MultimodalPorts()
|
|
870
|
+
video = Path(str(path))
|
|
871
|
+
dest = Path(str(dest_dir))
|
|
872
|
+
wanted = max(1, int(count))
|
|
873
|
+
if ports.keyframe_extractor is not None:
|
|
874
|
+
return _injected_keyframes(ports.keyframe_extractor, video, dest, wanted)
|
|
875
|
+
binary = _which_ffmpeg()
|
|
876
|
+
if binary is None:
|
|
877
|
+
return {"status": "unavailable", "frames": [], "detail": VIDEO_UNAVAILABLE_DETAIL}
|
|
878
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
879
|
+
args = [
|
|
880
|
+
"-nostdin", "-loglevel", "error", "-y",
|
|
881
|
+
"-i", str(video),
|
|
882
|
+
"-vf", f"thumbnail={KEYFRAME_WINDOW}",
|
|
883
|
+
"-frames:v", str(wanted),
|
|
884
|
+
"-vsync", "vfr",
|
|
885
|
+
str(dest / "keyframe-%03d.jpg"),
|
|
886
|
+
]
|
|
887
|
+
try:
|
|
888
|
+
code = _run_ffmpeg(binary, args)
|
|
889
|
+
except Exception as exc: # noqa: BLE001 — a broken decoder is a state
|
|
890
|
+
return {"status": "failed", "frames": [], "detail": f"ffmpeg failed: {exc}"}
|
|
891
|
+
frames = sorted(str(p) for p in dest.glob("keyframe-*.jpg"))
|
|
892
|
+
if code != 0 and not frames:
|
|
893
|
+
return {
|
|
894
|
+
"status": "failed",
|
|
895
|
+
"frames": [],
|
|
896
|
+
"detail": f"ffmpeg exited with status {code}",
|
|
897
|
+
}
|
|
898
|
+
if not frames:
|
|
899
|
+
return {
|
|
900
|
+
"status": "empty",
|
|
901
|
+
"frames": [],
|
|
902
|
+
"detail": "ffmpeg produced no frames from this video",
|
|
903
|
+
}
|
|
904
|
+
return {"status": "ok", "frames": frames[:wanted], "detail": ""}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _injected_keyframes(
|
|
908
|
+
extractor: Callable[..., Any], video: Path, dest: Path, wanted: int
|
|
909
|
+
) -> Dict[str, Any]:
|
|
910
|
+
"""Run a caller-supplied extractor; a failure is reported, never raised."""
|
|
911
|
+
try:
|
|
912
|
+
produced = extractor(str(video), str(dest), wanted)
|
|
913
|
+
except Exception as exc: # noqa: BLE001 — an injected port is not trusted more
|
|
914
|
+
return {"status": "failed", "frames": [], "detail": f"keyframe port failed: {exc}"}
|
|
915
|
+
frames = [str(item) for item in (produced or [])][:wanted]
|
|
916
|
+
if not frames:
|
|
917
|
+
return {
|
|
918
|
+
"status": "empty",
|
|
919
|
+
"frames": [],
|
|
920
|
+
"detail": "the keyframe port produced no frames",
|
|
921
|
+
}
|
|
922
|
+
return {"status": "ok", "frames": frames, "detail": ""}
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def read_video_facts(
|
|
926
|
+
path: Any,
|
|
927
|
+
dest_dir: Any,
|
|
928
|
+
*,
|
|
929
|
+
count: int = DEFAULT_KEYFRAMES,
|
|
930
|
+
ports: Optional[MultimodalPorts] = None,
|
|
931
|
+
subtitle_text: Optional[str] = None,
|
|
932
|
+
) -> VideoFacts:
|
|
933
|
+
"""Observe one video: its keyframes and its companion subtitles."""
|
|
934
|
+
facts = VideoFacts(path=str(path))
|
|
935
|
+
outcome = extract_keyframes(path, dest_dir, count=count, ports=ports)
|
|
936
|
+
facts.keyframe_status = str(outcome["status"])
|
|
937
|
+
facts.keyframes = list(outcome["frames"])
|
|
938
|
+
facts.keyframe_detail = str(outcome["detail"])
|
|
939
|
+
supplied = str(subtitle_text or "").strip()
|
|
940
|
+
if supplied:
|
|
941
|
+
facts.subtitle_status = "ok"
|
|
942
|
+
facts.subtitle_text = parse_subtitles(supplied)
|
|
943
|
+
return facts
|
|
944
|
+
companion = find_subtitle(path)
|
|
945
|
+
if companion is None:
|
|
946
|
+
return facts
|
|
947
|
+
facts.subtitle_path = str(companion)
|
|
948
|
+
try:
|
|
949
|
+
raw = companion.read_text(encoding="utf-8", errors="ignore")
|
|
950
|
+
except OSError as exc:
|
|
951
|
+
facts.subtitle_status = "unreadable"
|
|
952
|
+
facts.keyframe_detail = (facts.keyframe_detail or "").strip()
|
|
953
|
+
facts.subtitle_text = ""
|
|
954
|
+
quiet()
|
|
955
|
+
facts.subtitle_path = f"{companion} ({exc.strerror or 'unreadable'})"
|
|
956
|
+
return facts
|
|
957
|
+
parsed = parse_subtitles(raw)
|
|
958
|
+
facts.subtitle_status = "ok" if parsed else "empty"
|
|
959
|
+
facts.subtitle_text = parsed
|
|
960
|
+
return facts
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def video_quality_score(facts: VideoFacts) -> Dict[str, Any]:
|
|
964
|
+
"""How much of this video the Brain can actually retrieve later.
|
|
965
|
+
|
|
966
|
+
Same principle as :func:`image_quality_score`: not a judgement about the
|
|
967
|
+
footage. Subtitles are worth the most (they are the words), keyframes are
|
|
968
|
+
worth something (they can be OCR'd and seen), and a video with neither is
|
|
969
|
+
a filename.
|
|
970
|
+
"""
|
|
971
|
+
reasons: List[str] = []
|
|
972
|
+
score = 0.1 # we know it is a video and where it lives
|
|
973
|
+
if facts.subtitle_text:
|
|
974
|
+
score += 0.55 * min(1.0, len(facts.subtitle_text) / 400.0)
|
|
975
|
+
reasons.append("subtitles")
|
|
976
|
+
else:
|
|
977
|
+
reasons.append(f"no_subtitles_{facts.subtitle_status}")
|
|
978
|
+
if facts.keyframes:
|
|
979
|
+
score += min(0.35, 0.1 * len(facts.keyframes))
|
|
980
|
+
reasons.append("keyframes")
|
|
981
|
+
else:
|
|
982
|
+
reasons.append(f"no_keyframes_{facts.keyframe_status}")
|
|
983
|
+
return {"score": round(max(0.0, min(1.0, score)), 4), "reasons": reasons}
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def video_node_id(content_hash: str, workspace_id: Optional[str] = None) -> str:
|
|
987
|
+
"""Workspace-scoped, content-addressed id — re-ingesting is idempotent."""
|
|
988
|
+
scoped = f"{workspace_id or 'legacy-global'}|{content_hash}"
|
|
989
|
+
return f"video:{_sha256_text(scoped)[:24]}"
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def video_frame_dir(blob_dir: Any, content_hash: str) -> Path:
|
|
993
|
+
"""Where this video's stills live: content-addressed, stable across runs.
|
|
994
|
+
|
|
995
|
+
Deliberately under the Brain's own blob directory rather than a temp dir.
|
|
996
|
+
A frame referenced by an ``Image`` node has to still be there the next time
|
|
997
|
+
someone opens that memory, and a backup that copies the blobs copies these.
|
|
998
|
+
"""
|
|
999
|
+
return Path(str(blob_dir)) / "video_frames" / str(content_hash)[:32]
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def write_video_memory(
|
|
1003
|
+
store: Any,
|
|
1004
|
+
*,
|
|
1005
|
+
path: Path,
|
|
1006
|
+
facts: VideoFacts,
|
|
1007
|
+
title: str,
|
|
1008
|
+
source_type: str = MODALITY_VIDEO,
|
|
1009
|
+
source_uri: Optional[str] = None,
|
|
1010
|
+
owner: Optional[str] = None,
|
|
1011
|
+
workspace_id: Optional[str] = None,
|
|
1012
|
+
conversation_id: Optional[str] = None,
|
|
1013
|
+
captured_at: Optional[str] = None,
|
|
1014
|
+
modified_at: Optional[str] = None,
|
|
1015
|
+
permissions: Optional[Dict[str, Any]] = None,
|
|
1016
|
+
extra_metadata: Optional[Dict[str, Any]] = None,
|
|
1017
|
+
ports: Optional[MultimodalPorts] = None,
|
|
1018
|
+
) -> Dict[str, Any]:
|
|
1019
|
+
"""Write one ``Video`` node, its keyframes, and its subtitles.
|
|
1020
|
+
|
|
1021
|
+
Each keyframe goes through the **existing image path** — the same
|
|
1022
|
+
:func:`extract_image_facts` and :func:`write_image_memory` a photograph
|
|
1023
|
+
uses — so a still from a video is OCR'd, captioned, vectorized and made
|
|
1024
|
+
searchable by exactly the machinery that already does that, and joined to
|
|
1025
|
+
its video by ``CONTAINS_IMAGE``. Subtitles ride the ordinary text index as
|
|
1026
|
+
chunks. Nothing about video gets its own retrieval path.
|
|
1027
|
+
|
|
1028
|
+
The frames are written before the video node so their (short-lived) write
|
|
1029
|
+
transactions never nest inside the video's.
|
|
1030
|
+
"""
|
|
1031
|
+
ports = ports or MultimodalPorts()
|
|
1032
|
+
captured_at = captured_at or utc_now_iso()
|
|
1033
|
+
content_hash = _sha256_file(path)
|
|
1034
|
+
node_id = video_node_id(content_hash, workspace_id)
|
|
1035
|
+
frames = _write_keyframes(
|
|
1036
|
+
store,
|
|
1037
|
+
facts=facts,
|
|
1038
|
+
title=title,
|
|
1039
|
+
owner=owner,
|
|
1040
|
+
workspace_id=workspace_id,
|
|
1041
|
+
conversation_id=conversation_id,
|
|
1042
|
+
captured_at=captured_at,
|
|
1043
|
+
permissions=permissions,
|
|
1044
|
+
ports=ports,
|
|
1045
|
+
)
|
|
1046
|
+
body = facts.subtitle_text or (
|
|
1047
|
+
f"[{MODALITY_VIDEO}] {title}\n"
|
|
1048
|
+
"이 영상에는 자막이 없어 말의 내용은 검색되지 않습니다 — "
|
|
1049
|
+
"대신 대표 장면 이미지로 찾을 수 있습니다."
|
|
1050
|
+
)
|
|
1051
|
+
metadata: Dict[str, Any] = {
|
|
1052
|
+
"filename": path.name,
|
|
1053
|
+
"file_path": str(path),
|
|
1054
|
+
"ext": path.suffix.lower(),
|
|
1055
|
+
"bytes": path.stat().st_size,
|
|
1056
|
+
"sha256": content_hash,
|
|
1057
|
+
"content_hash": content_hash,
|
|
1058
|
+
"source_type": source_type,
|
|
1059
|
+
"source_uri": source_uri or str(path),
|
|
1060
|
+
"captured_at": captured_at,
|
|
1061
|
+
"modified_at": modified_at,
|
|
1062
|
+
"owner": owner,
|
|
1063
|
+
"workspace_id": workspace_id,
|
|
1064
|
+
"permissions": permissions or {},
|
|
1065
|
+
"conversation_id": conversation_id,
|
|
1066
|
+
"keyframe_nodes": [frame["node_id"] for frame in frames],
|
|
1067
|
+
**facts.as_metadata(),
|
|
1068
|
+
**(extra_metadata or {}),
|
|
1069
|
+
}
|
|
1070
|
+
# Honest card: a video nobody captioned says so, rather than rendering as a
|
|
1071
|
+
# blank summary that looks like a failed read.
|
|
1072
|
+
summary = body[:SUMMARY_CHARS]
|
|
1073
|
+
chunk_ids: List[str] = []
|
|
1074
|
+
with store._connect() as conn:
|
|
1075
|
+
duplicate = (
|
|
1076
|
+
conn.execute("SELECT 1 FROM nodes WHERE id=? LIMIT 1", (node_id,)).fetchone()
|
|
1077
|
+
is not None
|
|
1078
|
+
)
|
|
1079
|
+
store._upsert_node(
|
|
1080
|
+
conn,
|
|
1081
|
+
node_id,
|
|
1082
|
+
VIDEO_NODE_TYPE,
|
|
1083
|
+
title or path.name,
|
|
1084
|
+
summary=summary,
|
|
1085
|
+
metadata=metadata,
|
|
1086
|
+
raw=metadata,
|
|
1087
|
+
owner=owner,
|
|
1088
|
+
workspace_id=workspace_id,
|
|
1089
|
+
)
|
|
1090
|
+
for frame in frames:
|
|
1091
|
+
store._upsert_edge(
|
|
1092
|
+
conn,
|
|
1093
|
+
node_id,
|
|
1094
|
+
frame["node_id"],
|
|
1095
|
+
VIDEO_FRAME_RELATION,
|
|
1096
|
+
weight=0.8,
|
|
1097
|
+
metadata={
|
|
1098
|
+
"source": VIDEO_FRAME_SOURCE_TYPE,
|
|
1099
|
+
"index": frame["index"],
|
|
1100
|
+
"workspace_id": workspace_id,
|
|
1101
|
+
},
|
|
1102
|
+
)
|
|
1103
|
+
for index, piece in enumerate(_split_index_text(facts.subtitle_text)):
|
|
1104
|
+
chunk_id = f"chunk:{_sha256_text(f'{node_id}:{index}:{piece}')[:24]}"
|
|
1105
|
+
chunk_ids.append(chunk_id)
|
|
1106
|
+
chunk_meta = {
|
|
1107
|
+
"index": index,
|
|
1108
|
+
"source_node": node_id,
|
|
1109
|
+
"workspace_id": workspace_id,
|
|
1110
|
+
"modality": MODALITY_VIDEO,
|
|
1111
|
+
}
|
|
1112
|
+
store._upsert_node(
|
|
1113
|
+
conn,
|
|
1114
|
+
chunk_id,
|
|
1115
|
+
"Chunk",
|
|
1116
|
+
f"{path.name} chunk {index + 1}",
|
|
1117
|
+
summary=piece[:SUMMARY_CHARS],
|
|
1118
|
+
metadata=chunk_meta,
|
|
1119
|
+
owner=owner,
|
|
1120
|
+
workspace_id=workspace_id,
|
|
1121
|
+
)
|
|
1122
|
+
store._upsert_chunk(
|
|
1123
|
+
conn,
|
|
1124
|
+
chunk_id=chunk_id,
|
|
1125
|
+
source_node=node_id,
|
|
1126
|
+
text=piece,
|
|
1127
|
+
metadata=chunk_meta,
|
|
1128
|
+
)
|
|
1129
|
+
store._upsert_edge(conn, node_id, chunk_id, "포함함")
|
|
1130
|
+
concept_ids = _attach_concepts(
|
|
1131
|
+
store,
|
|
1132
|
+
conn,
|
|
1133
|
+
node_id=node_id,
|
|
1134
|
+
text=facts.subtitle_text,
|
|
1135
|
+
owner=owner,
|
|
1136
|
+
workspace_id=workspace_id,
|
|
1137
|
+
)
|
|
1138
|
+
source_node_id = _attach_source(
|
|
1139
|
+
store,
|
|
1140
|
+
conn,
|
|
1141
|
+
node_id=node_id,
|
|
1142
|
+
source_type=source_type,
|
|
1143
|
+
source_uri=source_uri or str(path),
|
|
1144
|
+
title=title or path.name,
|
|
1145
|
+
content_hash=content_hash,
|
|
1146
|
+
captured_at=captured_at,
|
|
1147
|
+
owner=owner,
|
|
1148
|
+
workspace_id=workspace_id,
|
|
1149
|
+
)
|
|
1150
|
+
metadata["concepts"] = concept_ids
|
|
1151
|
+
return {
|
|
1152
|
+
"node_id": node_id,
|
|
1153
|
+
"type": VIDEO_NODE_TYPE,
|
|
1154
|
+
"title": title or path.name,
|
|
1155
|
+
"sha256": content_hash,
|
|
1156
|
+
"content_hash": content_hash,
|
|
1157
|
+
"source_node_id": source_node_id,
|
|
1158
|
+
"chunk_ids": chunk_ids,
|
|
1159
|
+
"chunk_count": len(chunk_ids),
|
|
1160
|
+
"duplicate": duplicate,
|
|
1161
|
+
"captured_at": captured_at,
|
|
1162
|
+
"keyframes": frames,
|
|
1163
|
+
"metadata": metadata,
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _write_keyframes(
|
|
1168
|
+
store: Any,
|
|
1169
|
+
*,
|
|
1170
|
+
facts: VideoFacts,
|
|
1171
|
+
title: str,
|
|
1172
|
+
owner: Optional[str],
|
|
1173
|
+
workspace_id: Optional[str],
|
|
1174
|
+
conversation_id: Optional[str],
|
|
1175
|
+
captured_at: str,
|
|
1176
|
+
permissions: Optional[Dict[str, Any]],
|
|
1177
|
+
ports: MultimodalPorts,
|
|
1178
|
+
) -> List[Dict[str, Any]]:
|
|
1179
|
+
"""Every extracted still, through the ordinary image door."""
|
|
1180
|
+
written: List[Dict[str, Any]] = []
|
|
1181
|
+
for index, frame_path in enumerate(facts.keyframes):
|
|
1182
|
+
frame = Path(frame_path)
|
|
1183
|
+
image_facts = extract_image_facts(str(frame), ports=ports)
|
|
1184
|
+
if not image_facts.readable:
|
|
1185
|
+
# A frame ffmpeg wrote that Pillow cannot open is a state worth
|
|
1186
|
+
# skipping, not worth failing the whole video over.
|
|
1187
|
+
continue
|
|
1188
|
+
result = write_image_memory(
|
|
1189
|
+
store,
|
|
1190
|
+
path=frame,
|
|
1191
|
+
facts=image_facts,
|
|
1192
|
+
title=f"{title} · 장면 {index + 1}",
|
|
1193
|
+
source_type=VIDEO_FRAME_SOURCE_TYPE,
|
|
1194
|
+
source_uri=str(frame),
|
|
1195
|
+
owner=owner,
|
|
1196
|
+
workspace_id=workspace_id,
|
|
1197
|
+
conversation_id=conversation_id,
|
|
1198
|
+
captured_at=captured_at,
|
|
1199
|
+
permissions=permissions,
|
|
1200
|
+
extra_metadata={
|
|
1201
|
+
"modality": MODALITY_IMAGE,
|
|
1202
|
+
"video_path": facts.path,
|
|
1203
|
+
"keyframe_index": index,
|
|
1204
|
+
},
|
|
1205
|
+
)
|
|
1206
|
+
written.append({
|
|
1207
|
+
"node_id": result["node_id"],
|
|
1208
|
+
"index": index,
|
|
1209
|
+
"path": str(frame),
|
|
1210
|
+
"ocr_status": image_facts.ocr_status,
|
|
1211
|
+
"vision_embedding": image_facts.embedding_status,
|
|
1212
|
+
})
|
|
1213
|
+
return written
|
|
1214
|
+
|
|
1215
|
+
|
|
714
1216
|
__all__ = [
|
|
715
1217
|
"AUDIO_EXTENSIONS",
|
|
1218
|
+
"DEFAULT_KEYFRAMES",
|
|
1219
|
+
"FFMPEG_BINARY",
|
|
716
1220
|
"IMAGE_CHUNK_CHARS",
|
|
717
1221
|
"IMAGE_EXTENSIONS",
|
|
718
1222
|
"MAX_INDEX_TEXT_CHARS",
|
|
1223
|
+
"MAX_SUBTITLE_CHARS",
|
|
719
1224
|
"MAX_THUMBNAIL_CHARS",
|
|
720
1225
|
"MODALITY_AUDIO",
|
|
721
1226
|
"MODALITY_IMAGE",
|
|
722
1227
|
"MODALITY_TEXT",
|
|
723
1228
|
"MODALITY_VIDEO",
|
|
1229
|
+
"SUBTITLE_EXTENSIONS",
|
|
724
1230
|
"SUMMARY_CHARS",
|
|
725
1231
|
"THUMBNAIL_EDGE",
|
|
726
1232
|
"VIDEO_EXTENSIONS",
|
|
1233
|
+
"VIDEO_FRAME_RELATION",
|
|
1234
|
+
"VIDEO_FRAME_SOURCE_TYPE",
|
|
1235
|
+
"VIDEO_NODE_TYPE",
|
|
727
1236
|
"VIDEO_OUT_OF_SCOPE",
|
|
1237
|
+
"VIDEO_UNAVAILABLE_DETAIL",
|
|
728
1238
|
"AudioFacts",
|
|
729
1239
|
"ImageFacts",
|
|
730
1240
|
"MultimodalPorts",
|
|
1241
|
+
"VideoFacts",
|
|
731
1242
|
"audio_quality_score",
|
|
732
1243
|
"detect_modality",
|
|
733
1244
|
"extract_image_facts",
|
|
1245
|
+
"extract_keyframes",
|
|
1246
|
+
"ffmpeg_available",
|
|
1247
|
+
"find_subtitle",
|
|
734
1248
|
"image_node_id",
|
|
735
1249
|
"image_quality_score",
|
|
1250
|
+
"parse_subtitles",
|
|
1251
|
+
"read_video_facts",
|
|
736
1252
|
"transcribe_audio",
|
|
1253
|
+
"video_frame_dir",
|
|
1254
|
+
"video_node_id",
|
|
1255
|
+
"video_quality_score",
|
|
737
1256
|
"write_image_memory",
|
|
1257
|
+
"write_video_memory",
|
|
738
1258
|
]
|