pixcull 2.43.4__py3-none-any.whl
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.
- pixcull/__init__.py +55 -0
- pixcull/__main__.py +4 -0
- pixcull/audio_sync.py +269 -0
- pixcull/cli.py +1377 -0
- pixcull/config.py +85 -0
- pixcull/db/__init__.py +0 -0
- pixcull/db/models.py +33 -0
- pixcull/detectors/__init__.py +3 -0
- pixcull/detectors/base.py +30 -0
- pixcull/detectors/blur.py +60 -0
- pixcull/detectors/canon.py +407 -0
- pixcull/detectors/composition.py +224 -0
- pixcull/detectors/duplicate.py +198 -0
- pixcull/detectors/exposure.py +32 -0
- pixcull/detectors/face.py +283 -0
- pixcull/detectors/scene.py +200 -0
- pixcull/detectors/subject.py +31 -0
- pixcull/detectors/wedding_moment.py +71 -0
- pixcull/error_reporting.py +264 -0
- pixcull/events.py +160 -0
- pixcull/i18n.py +175 -0
- pixcull/io/__init__.py +5 -0
- pixcull/io/exif.py +161 -0
- pixcull/io/exif_audit.py +170 -0
- pixcull/io/formats.py +8 -0
- pixcull/io/gpmf.py +513 -0
- pixcull/io/gps_map.py +127 -0
- pixcull/io/icc.py +205 -0
- pixcull/io/iptc_embed.py +166 -0
- pixcull/io/loader.py +272 -0
- pixcull/io/raw_proxy.py +132 -0
- pixcull/io/reel_assembly.py +577 -0
- pixcull/io/video.py +495 -0
- pixcull/io/xmp.py +457 -0
- pixcull/license/__init__.py +521 -0
- pixcull/llm_budget.py +213 -0
- pixcull/locale/ar_SA.json +194 -0
- pixcull/locale/de_DE.json +194 -0
- pixcull/locale/en_US.json +194 -0
- pixcull/locale/es_ES.json +194 -0
- pixcull/locale/fr_FR.json +194 -0
- pixcull/locale/it_IT.json +194 -0
- pixcull/locale/ja_JP.json +194 -0
- pixcull/locale/ko_KR.json +194 -0
- pixcull/locale/nl_NL.json +194 -0
- pixcull/locale/pt_BR.json +194 -0
- pixcull/locale/ru_RU.json +194 -0
- pixcull/locale/tr_TR.json +194 -0
- pixcull/locale/zh_CN.json +194 -0
- pixcull/models_manager.py +262 -0
- pixcull/phrase_generator.py +483 -0
- pixcull/pipeline/__init__.py +4 -0
- pixcull/pipeline/burst_peak.py +267 -0
- pixcull/pipeline/face_audit.py +199 -0
- pixcull/pipeline/face_clustering.py +528 -0
- pixcull/pipeline/face_library.py +261 -0
- pixcull/pipeline/location_clustering.py +189 -0
- pixcull/pipeline/orchestrator.py +614 -0
- pixcull/pipeline/parallel.py +261 -0
- pixcull/pipeline/worker.py +231 -0
- pixcull/plugins/__init__.py +422 -0
- pixcull/plugins/builtin/example_wildlife.py +39 -0
- pixcull/policy_tuner.py +432 -0
- pixcull/preferred_axes.py +146 -0
- pixcull/qrcode_svg.py +488 -0
- pixcull/report/__init__.py +13 -0
- pixcull/report/contact_sheet.py +278 -0
- pixcull/report/csv.py +15 -0
- pixcull/report/executive_pdf.py +946 -0
- pixcull/report/gallery.py +303 -0
- pixcull/report/serve_app.py +13014 -0
- pixcull/report/serve_util.py +139 -0
- pixcull/report/templates/pages/admin.html +661 -0
- pixcull/report/templates/pages/admin_perf.html +475 -0
- pixcull/report/templates/pages/disagreement.html +1 -0
- pixcull/report/templates/pages/first_run.html +210 -0
- pixcull/report/templates/pages/history.html +143 -0
- pixcull/report/templates/pages/library.html +205 -0
- pixcull/report/templates/pages/privacy.html +109 -0
- pixcull/report/templates/pages/tether.html +220 -0
- pixcull/report/templates/pages/upload.html +1715 -0
- pixcull/report/templates/pages/vertical_bulk.html +331 -0
- pixcull/report/templates/pages/verticals.html +1848 -0
- pixcull/report/templates/report.html.j2 +30 -0
- pixcull/report/templates/results.html +19224 -0
- pixcull/report/templates/timeline.html +91 -0
- pixcull/report/templates/video_review.html +517 -0
- pixcull/scoring/__init__.py +44 -0
- pixcull/scoring/aesthetic.py +55 -0
- pixcull/scoring/attribution.py +265 -0
- pixcull/scoring/audio_events.py +392 -0
- pixcull/scoring/audio_tagger.py +353 -0
- pixcull/scoring/axis_rescorer.py +264 -0
- pixcull/scoring/bias_audit.py +411 -0
- pixcull/scoring/burst_peak.py +494 -0
- pixcull/scoring/caption_gen.py +382 -0
- pixcull/scoring/color_grade.py +243 -0
- pixcull/scoring/composition_classifier.py +255 -0
- pixcull/scoring/counterfactual.py +201 -0
- pixcull/scoring/data/audio_tagger_thresholds.json +4 -0
- pixcull/scoring/decision.py +175 -0
- pixcull/scoring/dup_frames.py +113 -0
- pixcull/scoring/eval_metrics.py +296 -0
- pixcull/scoring/fusion.py +175 -0
- pixcull/scoring/genre_strategies.py +279 -0
- pixcull/scoring/hard_examples.py +187 -0
- pixcull/scoring/library_index.py +504 -0
- pixcull/scoring/meta_judge.py +458 -0
- pixcull/scoring/near_dup.py +175 -0
- pixcull/scoring/nl_explain.py +175 -0
- pixcull/scoring/personal_learn.py +199 -0
- pixcull/scoring/personalized.py +178 -0
- pixcull/scoring/photo_advice.py +1576 -0
- pixcull/scoring/photography_canon.py +355 -0
- pixcull/scoring/reel.py +666 -0
- pixcull/scoring/reel_caption.py +465 -0
- pixcull/scoring/rescorer.py +190 -0
- pixcull/scoring/rubric.py +271 -0
- pixcull/scoring/rubric_decompose.py +359 -0
- pixcull/scoring/scenes.py +156 -0
- pixcull/scoring/self_tune.py +292 -0
- pixcull/scoring/semantic_search.py +190 -0
- pixcull/scoring/shot_boundaries.py +123 -0
- pixcull/scoring/style_guide.py +355 -0
- pixcull/scoring/style_modes.py +300 -0
- pixcull/scoring/templates/__init__.py +0 -0
- pixcull/scoring/templates/scene_templates.yaml +197 -0
- pixcull/scoring/temporal.py +669 -0
- pixcull/scoring/transcribe.py +553 -0
- pixcull/scoring/video_quality.py +320 -0
- pixcull/scoring/vlm_judge.py +656 -0
- pixcull/scoring/vlm_vertical_prompts.py +133 -0
- pixcull/scoring/wedding_moments.py +261 -0
- pixcull/shortcuts.py +268 -0
- pixcull/shortlink.py +196 -0
- pixcull/style/__init__.py +51 -0
- pixcull/style/clip_clone.py +228 -0
- pixcull/style/clone.py +237 -0
- pixcull/sync/__init__.py +84 -0
- pixcull/sync/discovery.py +277 -0
- pixcull/sync/event.py +397 -0
- pixcull/sync/presence.py +219 -0
- pixcull/sync/push.py +217 -0
- pixcull/sync/webrtc.py +217 -0
- pixcull/sync.py +319 -0
- pixcull/team/__init__.py +38 -0
- pixcull/team/roles.py +111 -0
- pixcull/team/taste.py +162 -0
- pixcull/telemetry.py +175 -0
- pixcull/tether.py +369 -0
- pixcull/tether_stream.py +161 -0
- pixcull/unsplash.py +394 -0
- pixcull/users.py +354 -0
- pixcull/utils/__init__.py +0 -0
- pixcull/utils/image.py +15 -0
- pixcull/utils/logging.py +26 -0
- pixcull/utils/timing.py +21 -0
- pixcull/verticals.py +549 -0
- pixcull/workflow.py +326 -0
- pixcull-2.43.4.dist-info/METADATA +147 -0
- pixcull-2.43.4.dist-info/RECORD +164 -0
- pixcull-2.43.4.dist-info/WHEEL +4 -0
- pixcull-2.43.4.dist-info/entry_points.txt +2 -0
- pixcull-2.43.4.dist-info/licenses/LICENSE +21 -0
pixcull/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""PixCull — AI photo culling & scoring."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# v2.19 — single-source the version from package metadata (pyproject);
|
|
6
|
+
# the literal is only the fallback for running from a raw source tree.
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import version as _pkg_version
|
|
9
|
+
__version__ = _pkg_version("pixcull")
|
|
10
|
+
except Exception:
|
|
11
|
+
__version__ = "2.43.4"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _check_numpy_compatibility() -> None:
|
|
15
|
+
"""ROADMAP INFRA-5 — runtime guard against the numpy 2.x regression.
|
|
16
|
+
|
|
17
|
+
We've been bitten twice (V18.1 and V22.0.1) when a transitive
|
|
18
|
+
``pip install`` upgraded numpy to 2.x. The symptoms are subtle:
|
|
19
|
+
mediapipe imports cleanly but its face detector silently returns
|
|
20
|
+
empty results, and pre-V18.3 rescorer joblibs fail to unpickle
|
|
21
|
+
because the ``numpy.random._pcg64.PCG64`` paths differ between
|
|
22
|
+
1.x and 2.x. Neither failure is loud — face_count just stays 0
|
|
23
|
+
on all images, the rescorer silently falls back to rule-only.
|
|
24
|
+
|
|
25
|
+
Pin in pyproject.toml is ``numpy>=1.26,<2``, but a third-party
|
|
26
|
+
install command (``pip install some-other-package``) can still
|
|
27
|
+
blow past the pin. We don't fail hard here — that would break
|
|
28
|
+
perfectly fine pipelines that don't touch faces — but we DO
|
|
29
|
+
print a loud warning on every import so the regression is
|
|
30
|
+
visible at the top of the log.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
import numpy
|
|
34
|
+
except ImportError:
|
|
35
|
+
return # numpy missing is a different problem; let downstream report
|
|
36
|
+
ver = getattr(numpy, "__version__", "")
|
|
37
|
+
try:
|
|
38
|
+
major = int(ver.split(".")[0])
|
|
39
|
+
except (ValueError, IndexError):
|
|
40
|
+
return
|
|
41
|
+
if major >= 2:
|
|
42
|
+
print(
|
|
43
|
+
"\n"
|
|
44
|
+
"⚠ PixCull: numpy " + ver + " detected.\n"
|
|
45
|
+
" mediapipe (face detector) and the V18.3 rescorer joblibs\n"
|
|
46
|
+
" need numpy 1.x. Run:\n"
|
|
47
|
+
" pip install 'numpy<2'\n"
|
|
48
|
+
" to fix. Without this, faces won't be detected (face_count\n"
|
|
49
|
+
" will be 0 on every photo) and the rescorer falls back to\n"
|
|
50
|
+
" rule-only mode.\n",
|
|
51
|
+
file=sys.stderr,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_check_numpy_compatibility()
|
pixcull/__main__.py
ADDED
pixcull/audio_sync.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""v0.10-P1-4 — audio-photo sync for the wedding tether flow.
|
|
2
|
+
|
|
3
|
+
Experimental. Wedding-day shooters often want photos that
|
|
4
|
+
correspond to specific ceremony moments (vows / ring exchange /
|
|
5
|
+
first kiss / cake cutting) auto-flagged with the right
|
|
6
|
+
``wedding_moment`` so the post-shoot delivery groups them
|
|
7
|
+
cleanly. The hard part is *detecting* the moment in real time —
|
|
8
|
+
we offload that to a client (iOS PixCullCompanion with
|
|
9
|
+
WhisperKit, or an external CLI hooked to the venue's PA system)
|
|
10
|
+
which sends us transcripts.
|
|
11
|
+
|
|
12
|
+
Wire model
|
|
13
|
+
==========
|
|
14
|
+
Client posts transcript chunks via:
|
|
15
|
+
|
|
16
|
+
POST /api/v1/runs/<run_id>/audio_sync
|
|
17
|
+
body: {
|
|
18
|
+
"transcripts": [
|
|
19
|
+
{"ts_ms": 1700000000000, "text": "I now pronounce you...",
|
|
20
|
+
"confidence": 0.92},
|
|
21
|
+
...
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
We:
|
|
26
|
+
|
|
27
|
+
1. Run keyword matching against a built-in vocabulary
|
|
28
|
+
(CEREMONY_KEYWORDS below). Returns the matched moments
|
|
29
|
+
each transcript hit.
|
|
30
|
+
2. Correlate transcript ts_ms ± window_s with photo capture
|
|
31
|
+
times (read from scores.csv's mtime column).
|
|
32
|
+
3. Boost the wedding_moment field on matched rows.
|
|
33
|
+
|
|
34
|
+
The keyword vocabulary is intentionally conservative — we want
|
|
35
|
+
high precision (false moment tags would mislead the delivery)
|
|
36
|
+
at the cost of recall. A photographer who wants more moments
|
|
37
|
+
matched can extend CEREMONY_KEYWORDS in their own deployment.
|
|
38
|
+
|
|
39
|
+
Opt-in only. PIXCULL_AUDIO_SYNC=1 env var required to enable
|
|
40
|
+
the route; otherwise the POST returns 403. Privacy guarantee:
|
|
41
|
+
audio bytes are NEVER sent to the server — only the transcript
|
|
42
|
+
text + timestamps. WhisperKit on iOS does all the STT locally.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
from typing import Iterable
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Keyword → wedding_moment vocabulary. Lowercased ASCII keys
|
|
53
|
+
# only — clients normalise transcripts to ASCII before sending.
|
|
54
|
+
# Format: phrase → moment_id. ``moment_id`` matches the
|
|
55
|
+
# canonical wedding_moment vocabulary used by
|
|
56
|
+
# pixcull.scoring.wedding_moment.WEDDING_MOMENTS.
|
|
57
|
+
#
|
|
58
|
+
# Conservative — only phrases that are extremely unlikely to
|
|
59
|
+
# appear outside the corresponding moment. "ring" alone is
|
|
60
|
+
# excluded (it shows up in too many other contexts). "the
|
|
61
|
+
# rings" / "exchange rings" are included because those phrasings
|
|
62
|
+
# are strongly ceremony-bound.
|
|
63
|
+
CEREMONY_KEYWORDS: dict[str, str] = {
|
|
64
|
+
# Vows
|
|
65
|
+
"i, take you to be my": "vows",
|
|
66
|
+
"to have and to hold": "vows",
|
|
67
|
+
"from this day forward": "vows",
|
|
68
|
+
"for better or for worse": "vows",
|
|
69
|
+
"till death do us part": "vows",
|
|
70
|
+
"vows": "vows",
|
|
71
|
+
"我愿意": "vows", # Chinese ceremony
|
|
72
|
+
|
|
73
|
+
# Ring exchange
|
|
74
|
+
"exchange rings": "ring_exchange",
|
|
75
|
+
"with this ring": "ring_exchange",
|
|
76
|
+
"place this ring": "ring_exchange",
|
|
77
|
+
"the rings": "ring_exchange",
|
|
78
|
+
"交换戒指": "ring_exchange",
|
|
79
|
+
|
|
80
|
+
# First kiss
|
|
81
|
+
"you may now kiss": "kiss",
|
|
82
|
+
"you may kiss the bride": "kiss",
|
|
83
|
+
"kiss the bride": "kiss",
|
|
84
|
+
"kiss the partner": "kiss",
|
|
85
|
+
"first kiss as": "kiss",
|
|
86
|
+
|
|
87
|
+
# Pronouncement
|
|
88
|
+
"i now pronounce you": "pronouncement",
|
|
89
|
+
"by the power vested in me": "pronouncement",
|
|
90
|
+
"husband and wife": "pronouncement",
|
|
91
|
+
"wife and wife": "pronouncement",
|
|
92
|
+
"husband and husband": "pronouncement",
|
|
93
|
+
|
|
94
|
+
# First dance
|
|
95
|
+
"first dance": "first_dance",
|
|
96
|
+
"their first dance": "first_dance",
|
|
97
|
+
"请新郎新娘跳第一支舞": "first_dance",
|
|
98
|
+
|
|
99
|
+
# Cake cutting
|
|
100
|
+
"cake cutting": "cake_cutting",
|
|
101
|
+
"cut the cake": "cake_cutting",
|
|
102
|
+
"cutting the cake": "cake_cutting",
|
|
103
|
+
"切蛋糕": "cake_cutting",
|
|
104
|
+
|
|
105
|
+
# Toasts
|
|
106
|
+
"to the bride and groom": "toast",
|
|
107
|
+
"raise a glass": "toast",
|
|
108
|
+
"raise our glasses": "toast",
|
|
109
|
+
"举杯": "toast",
|
|
110
|
+
|
|
111
|
+
# Chinese-tradition moments
|
|
112
|
+
"敬茶": "tea_ceremony",
|
|
113
|
+
"tea ceremony": "tea_ceremony",
|
|
114
|
+
"三鞠躬": "bow",
|
|
115
|
+
"跪拜": "bow",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Default time window: when a transcript matches at ts, photos
|
|
120
|
+
# whose mtime falls within ±window_s get the wedding_moment
|
|
121
|
+
# boost. 90 s is a Goldilocks default — large enough to cover
|
|
122
|
+
# "celebrant says 'I now pronounce you...' then 60s of kiss +
|
|
123
|
+
# applause" but small enough that consecutive ceremony moments
|
|
124
|
+
# (vows then ring then pronouncement) don't all get the same
|
|
125
|
+
# tag. Override via the POST body's `window_s` field.
|
|
126
|
+
DEFAULT_WINDOW_S = 90.0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_enabled() -> bool:
|
|
130
|
+
"""v0.10-P1-4 is opt-in to keep privacy posture conservative.
|
|
131
|
+
|
|
132
|
+
Photographers who want it set PIXCULL_AUDIO_SYNC=1 in their
|
|
133
|
+
shell env; the server route returns 403 otherwise so a
|
|
134
|
+
runaway client doesn't accidentally boost wedding moments
|
|
135
|
+
on a run that should have stayed scene-only.
|
|
136
|
+
"""
|
|
137
|
+
return os.environ.get("PIXCULL_AUDIO_SYNC", "").strip() in ("1", "true", "yes")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def match_transcript(text: str) -> str | None:
|
|
141
|
+
"""Return the wedding_moment matched by this transcript, or None.
|
|
142
|
+
|
|
143
|
+
Lowercases the input before matching so the vocab keys can
|
|
144
|
+
stay lowercase. Uses substring match (not regex) for both
|
|
145
|
+
speed and to keep the vocab obvious to a human auditor.
|
|
146
|
+
Matches the FIRST keyword found scanning the vocab in
|
|
147
|
+
insertion order — most-specific phrases first by convention.
|
|
148
|
+
"""
|
|
149
|
+
if not text:
|
|
150
|
+
return None
|
|
151
|
+
t = text.lower()
|
|
152
|
+
for kw, moment in CEREMONY_KEYWORDS.items():
|
|
153
|
+
if kw in t:
|
|
154
|
+
return moment
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def match_transcripts(
|
|
159
|
+
transcripts: Iterable[dict],
|
|
160
|
+
*,
|
|
161
|
+
min_confidence: float = 0.6,
|
|
162
|
+
) -> list[dict]:
|
|
163
|
+
"""Apply match_transcript to a batch, with confidence gate.
|
|
164
|
+
|
|
165
|
+
Returns the SUBSET of transcripts that matched, each enriched
|
|
166
|
+
with the inferred ``moment``. Drops anything below
|
|
167
|
+
min_confidence (defensive against noisy STT environments;
|
|
168
|
+
weddings are loud). Order is preserved from the input.
|
|
169
|
+
"""
|
|
170
|
+
out = []
|
|
171
|
+
for t in transcripts:
|
|
172
|
+
if not isinstance(t, dict):
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
ts_ms = int(t.get("ts_ms") or 0)
|
|
176
|
+
except (TypeError, ValueError):
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
conf = float(t.get("confidence") or 0)
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
conf = 0.0
|
|
182
|
+
text = str(t.get("text") or "")
|
|
183
|
+
if conf < min_confidence:
|
|
184
|
+
continue
|
|
185
|
+
moment = match_transcript(text)
|
|
186
|
+
if moment is None:
|
|
187
|
+
continue
|
|
188
|
+
out.append({
|
|
189
|
+
"ts_ms": ts_ms,
|
|
190
|
+
"text": text,
|
|
191
|
+
"moment": moment,
|
|
192
|
+
"confidence": conf,
|
|
193
|
+
})
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def correlate_with_rows(
|
|
198
|
+
matches: list[dict],
|
|
199
|
+
rows: list[dict],
|
|
200
|
+
*,
|
|
201
|
+
window_s: float = DEFAULT_WINDOW_S,
|
|
202
|
+
) -> dict[str, str]:
|
|
203
|
+
"""Assign wedding_moment to photo rows whose mtime falls
|
|
204
|
+
within ±window_s of any transcript match.
|
|
205
|
+
|
|
206
|
+
Returns a {filename: moment} dict — caller persists. Rows
|
|
207
|
+
that fall inside multiple moments' windows take the LATEST
|
|
208
|
+
match (the transcript chronologically closest "before" the
|
|
209
|
+
photo wins, because the photographer typically captures
|
|
210
|
+
AFTER the celebrant says the words).
|
|
211
|
+
|
|
212
|
+
Defensive against rows missing mtime: those are skipped
|
|
213
|
+
silently.
|
|
214
|
+
"""
|
|
215
|
+
if not matches or not rows:
|
|
216
|
+
return {}
|
|
217
|
+
window_ms = window_s * 1000.0
|
|
218
|
+
sorted_matches = sorted(matches, key=lambda m: m["ts_ms"])
|
|
219
|
+
out: dict[str, str] = {}
|
|
220
|
+
for row in rows:
|
|
221
|
+
if not isinstance(row, dict):
|
|
222
|
+
continue
|
|
223
|
+
fn = row.get("filename")
|
|
224
|
+
if not isinstance(fn, str) or not fn:
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
# rows store seconds-since-epoch; transcripts ms.
|
|
228
|
+
mtime_ms = float(row.get("mtime") or 0) * 1000.0
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
continue
|
|
231
|
+
if mtime_ms <= 0:
|
|
232
|
+
continue
|
|
233
|
+
# Walk matches from latest-before to earliest-before so
|
|
234
|
+
# the first valid window wins.
|
|
235
|
+
best: str | None = None
|
|
236
|
+
for m in reversed(sorted_matches):
|
|
237
|
+
if m["ts_ms"] - window_ms <= mtime_ms <= m["ts_ms"] + window_ms:
|
|
238
|
+
best = m["moment"]
|
|
239
|
+
break
|
|
240
|
+
if best is not None:
|
|
241
|
+
out[fn] = best
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def apply_audio_sync(
|
|
246
|
+
transcripts: Iterable[dict],
|
|
247
|
+
rows: list[dict],
|
|
248
|
+
*,
|
|
249
|
+
window_s: float = DEFAULT_WINDOW_S,
|
|
250
|
+
min_confidence: float = 0.6,
|
|
251
|
+
) -> dict:
|
|
252
|
+
"""Top-level — what the HTTP handler calls.
|
|
253
|
+
|
|
254
|
+
Returns the summary the client renders + the suggested
|
|
255
|
+
wedding_moment overrides. The actual write-back to
|
|
256
|
+
scores.csv / annotations.jsonl is the caller's job (the
|
|
257
|
+
server-side handler decides whether to persist or just
|
|
258
|
+
preview).
|
|
259
|
+
"""
|
|
260
|
+
matches = match_transcripts(transcripts, min_confidence=min_confidence)
|
|
261
|
+
suggestions = correlate_with_rows(matches, rows, window_s=window_s)
|
|
262
|
+
return {
|
|
263
|
+
"n_transcripts": len(list(transcripts) if not isinstance(transcripts, list) else transcripts),
|
|
264
|
+
"n_matched": len(matches),
|
|
265
|
+
"matches": matches,
|
|
266
|
+
"n_suggestions": len(suggestions),
|
|
267
|
+
"suggestions": suggestions,
|
|
268
|
+
"window_s": window_s,
|
|
269
|
+
}
|