This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 3aab881  Add multimodal SQL helper UDFs (#467)
3aab881 is described below

commit 3aab8812beeaa7d9100e1842b3d388c5a15160ea
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 7 14:20:09 2026 +0800

    Add multimodal SQL helper UDFs (#467)
---
 bindings/python/README.md                         |   6 +
 bindings/python/project-description.md            |  41 +++
 bindings/python/python/pypaimon_rust/functions.py | 357 ++++++++++++++++++++++
 bindings/python/src/context.rs                    |  91 +++++-
 bindings/python/tests/test_datafusion.py          | 138 ++++++++-
 docs/src/sql.md                                   |  51 ++++
 6 files changed, 673 insertions(+), 11 deletions(-)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index 1c908bc..6e67c8a 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -39,6 +39,12 @@ ctx.sql("INSERT INTO paimon.my_db.users VALUES (1, 'alice'), 
(2, 'bob')")
 # Query data
 batches = ctx.sql("SELECT id, name FROM paimon.my_db.users ORDER BY id")
 
+# Inspect BLOB media or build thumbnails when installed with 
pypaimon-rust[video]
+batches = ctx.sql(
+    "SELECT id, media_info(content), media_thumbnail(content, 160, 90) "
+    "FROM paimon.my_db.assets"
+)
+
 # Register a temporary table from a PyArrow RecordBatch
 batch = pa.record_batch([[1, 2], ["alice", "bob"]], names=["id", "name"])
 ctx.register_batch("paimon.default.my_temp", batch)
diff --git a/bindings/python/project-description.md 
b/bindings/python/project-description.md
index 0cab353..0db9978 100644
--- a/bindings/python/project-description.md
+++ b/bindings/python/project-description.md
@@ -53,6 +53,47 @@ ctx.sql("INSERT INTO paimon.my_db.users VALUES (1, 'alice'), 
(2, 'bob')")
 batches = ctx.sql("SELECT * FROM paimon.my_db.users")
 ```
 
+### Multimodal SQL Helpers
+
+`SQLContext` also registers Python scalar UDFs for common BLOB media and vector
+workflows. Install the optional media dependencies when you need image or video
+decoding:
+
+```shell
+pip install "pypaimon-rust[video]"
+```
+
+```python
+batches = ctx.sql("""
+    SELECT
+        id,
+        media_info(content) AS info_json,
+        media_thumbnail(content, 160, 90) AS preview_png,
+        video_snapshot(content, 1000) AS frame_png
+    FROM paimon.my_db.assets
+""")
+```
+
+For vector search, `vector_from_json` converts JSON-encoded embeddings into
+Arrow `List<Float32>` values that can be used from a temporary table or 
subquery:
+
+```python
+batches = ctx.sql("""
+    WITH queries AS (
+        SELECT id, vector_from_json(embedding_json) AS embedding
+        FROM paimon.my_db.query_embeddings
+    )
+    SELECT q.id AS query_id, r.id AS result_id
+    FROM queries q
+    CROSS JOIN LATERAL vector_search(
+        'paimon.my_db.items',
+        'embedding',
+        q.embedding,
+        10
+    ) AS r
+""")
+```
+
 ### Temporary Tables
 
 You can register temporary in-memory tables programmatically. Names support 
the same resolution rules as SQL: bare names use the current catalog and 
database, partially qualified names use the current catalog, and fully 
qualified names specify catalog.database.table.
diff --git a/bindings/python/python/pypaimon_rust/functions.py 
b/bindings/python/python/pypaimon_rust/functions.py
index 2460a36..73d61c4 100644
--- a/bindings/python/python/pypaimon_rust/functions.py
+++ b/bindings/python/python/pypaimon_rust/functions.py
@@ -16,7 +16,9 @@
 # under the License.
 
 import io
+import json
 import logging
+import math
 import struct
 from typing import Any, BinaryIO
 
@@ -126,11 +128,219 @@ def _encode_video_frame(frame, image_format: str) -> 
bytes:
         image = frame.to_image()
     except ImportError as e:
         raise ImportError("Pillow is required to encode video frame images") 
from e
+    return _encode_image(image, image_format)
+
+
+def _encode_image(image, image_format: str) -> bytes:
     output = io.BytesIO()
     image.save(output, format=image_format)
     return output.getvalue()
 
 
+def _rewind_stream(stream: BinaryIO) -> None:
+    try:
+        stream.seek(0)
+    except Exception:
+        pass
+
+
+def _json_dumps(value: Any) -> str:
+    return json.dumps(value, sort_keys=True, separators=(",", ":"))
+
+
+def _drop_none(value: dict[str, Any]) -> dict[str, Any]:
+    return {key: item for key, item in value.items() if item is not None}
+
+
+def _duration_millis(container, stream=None) -> int | None:
+    duration = getattr(container, "duration", None)
+    if duration is not None and duration >= 0:
+        return int(round(duration / 1000))
+
+    if stream is not None:
+        stream_duration = getattr(stream, "duration", None)
+        time_base = getattr(stream, "time_base", None)
+        if stream_duration is not None and stream_duration >= 0 and time_base 
is not None:
+            return int(round(float(stream_duration * time_base) * 1000))
+    return None
+
+
+def _codec_name(stream) -> str | None:
+    codec_context = getattr(stream, "codec_context", None)
+    return getattr(codec_context, "name", None)
+
+
+def _average_rate(stream) -> float | None:
+    rate = getattr(stream, "average_rate", None)
+    if rate is None:
+        return None
+    try:
+        return float(rate)
+    except Exception:
+        return None
+
+
+def _frame_count(stream) -> int | None:
+    frames = getattr(stream, "frames", None)
+    if frames is None or frames <= 0:
+        return None
+    return int(frames)
+
+
+def _decode_image_info(stream: BinaryIO) -> dict[str, Any] | None:
+    try:
+        from PIL import Image
+    except ImportError as e:
+        raise ImportError("Pillow is required to inspect image media") from e
+
+    with Image.open(stream) as image:
+        image.load()
+        return _drop_none(
+            {
+                "media_type": "image",
+                "format": image.format.lower() if image.format else None,
+                "width": image.width,
+                "height": image.height,
+                "mode": image.mode,
+            }
+        )
+
+
+def _decode_av_media_info(stream: BinaryIO) -> dict[str, Any] | None:
+    try:
+        import av
+    except ImportError as e:
+        raise ImportError("PyAV is required to inspect video or audio media") 
from e
+
+    with av.open(stream, mode="r") as container:
+        format_name = (container.format.name or "").lower() or None
+        format_names = set((container.format.name or "").split(","))
+        video_streams = list(container.streams.video)
+        audio_streams = list(container.streams.audio)
+
+        if video_streams:
+            stream0 = video_streams[0]
+            media_type = "image" if format_names & _STILL_IMAGE_FORMATS else 
"video"
+            info = {
+                "media_type": media_type,
+                "format": format_name,
+                "duration_ms": _duration_millis(container, stream0),
+                "width": getattr(stream0, "width", None),
+                "height": getattr(stream0, "height", None),
+                "codec": _codec_name(stream0),
+                "frame_count": _frame_count(stream0),
+                "average_rate": _average_rate(stream0),
+                "has_audio": bool(audio_streams),
+            }
+            return _drop_none(info)
+
+        if audio_streams:
+            stream0 = audio_streams[0]
+            info = {
+                "media_type": "audio",
+                "format": format_name,
+                "duration_ms": _duration_millis(container, stream0),
+                "codec": _codec_name(stream0),
+                "has_audio": True,
+            }
+            return _drop_none(info)
+    return None
+
+
+def _decode_media_info(stream: BinaryIO) -> str | None:
+    try:
+        info = _decode_image_info(stream)
+        if info is not None:
+            return _json_dumps(info)
+    except ImportError:
+        raise
+    except Exception:
+        _rewind_stream(stream)
+
+    info = _decode_av_media_info(stream)
+    return _json_dumps(info) if info is not None else None
+
+
+def _positive_dimension(value: Any, default: int) -> int | None:
+    try:
+        dimension = default if value is None else int(value)
+    except Exception:
+        return None
+    return dimension if dimension > 0 else None
+
+
+def _thumbnail_image(image, image_format: str, max_width: int, max_height: 
int) -> bytes:
+    try:
+        from PIL import Image
+    except ImportError as e:
+        raise ImportError("Pillow is required to encode media thumbnails") 
from e
+
+    thumbnail = image.copy()
+    resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", None)
+    if resampling is None:
+        thumbnail.thumbnail((max_width, max_height))
+    else:
+        thumbnail.thumbnail((max_width, max_height), resampling)
+    return _encode_image(thumbnail, image_format)
+
+
+def _decode_image_thumbnail(
+    stream: BinaryIO,
+    image_format: str,
+    max_width: int,
+    max_height: int,
+) -> bytes | None:
+    try:
+        from PIL import Image
+    except ImportError as e:
+        raise ImportError("Pillow is required to decode image thumbnails") 
from e
+
+    with Image.open(stream) as image:
+        image.load()
+        return _thumbnail_image(image, image_format, max_width, max_height)
+
+
+def _decode_video_thumbnail(
+    stream: BinaryIO,
+    image_format: str,
+    max_width: int,
+    max_height: int,
+) -> bytes | None:
+    try:
+        import av
+    except ImportError as e:
+        raise ImportError("PyAV is required to decode video thumbnails") from e
+
+    with av.open(stream, mode="r") as container:
+        if not container.streams.video:
+            return None
+        for frame in container.decode(video=0):
+            try:
+                image = frame.to_image()
+            except ImportError as e:
+                raise ImportError("Pillow is required to encode video 
thumbnails") from e
+            return _thumbnail_image(image, image_format, max_width, max_height)
+    return None
+
+
+def _decode_media_thumbnail(
+    stream: BinaryIO,
+    image_format: str,
+    max_width: int,
+    max_height: int,
+) -> bytes | None:
+    try:
+        thumbnail = _decode_image_thumbnail(stream, image_format, max_width, 
max_height)
+        if thumbnail is not None:
+            return thumbnail
+    except ImportError:
+        raise
+    except Exception:
+        _rewind_stream(stream)
+
+    return _decode_video_thumbnail(stream, image_format, max_width, max_height)
+
+
 def _decode_video_frame(
     stream: BinaryIO,
     image_format: str,
@@ -247,3 +457,150 @@ def _make_video_frame(image_format: str = "PNG", 
blob_reader_registry=None):
         return pa.array(frames, type=pa.binary())
 
     return video_frame
+
+
+def _make_media_info(blob_reader_registry=None):
+    def media_info(values):
+        try:
+            import pyarrow as pa
+        except ImportError as e:
+            raise ImportError("pyarrow is required to return media_info 
results") from e
+
+        infos = []
+        for raw_value in values.to_pylist():
+            if raw_value is None:
+                infos.append(None)
+                continue
+
+            try:
+                stream = open_blob_descriptor_stream(raw_value, 
blob_reader_registry)
+                try:
+                    infos.append(_decode_media_info(stream))
+                finally:
+                    stream.close()
+            except ImportError:
+                raise
+            except Exception as e:
+                logger.warning("Failed to inspect media: %s", e)
+                infos.append(None)
+
+        return pa.array(infos, type=pa.string())
+
+    return media_info
+
+
+def _make_media_thumbnail(
+    image_format: str = "PNG",
+    blob_reader_registry=None,
+    default_max_width: int = 320,
+    default_max_height: int = 320,
+):
+    image_format = image_format.upper()
+
+    def media_thumbnail(values, max_widths=None, max_heights=None):
+        try:
+            import pyarrow as pa
+        except ImportError as e:
+            raise ImportError("pyarrow is required to return media_thumbnail 
results") from e
+
+        thumbnails = []
+        raw_values = values.to_pylist()
+        if max_widths is None and max_heights is None:
+            width_values = [default_max_width] * len(raw_values)
+            height_values = [default_max_height] * len(raw_values)
+        elif max_widths is not None and max_heights is not None:
+            width_values = max_widths.to_pylist()
+            height_values = max_heights.to_pylist()
+        else:
+            raise ValueError("media_thumbnail requires both width and height 
arguments")
+        if len(width_values) != len(raw_values) or len(height_values) != 
len(raw_values):
+            raise ValueError("media_thumbnail size arguments must have the 
same row count")
+
+        for raw_value, max_width, max_height in zip(
+            raw_values, width_values, height_values
+        ):
+            if raw_value is None or max_width is None or max_height is None:
+                thumbnails.append(None)
+                continue
+
+            max_width = _positive_dimension(max_width, default_max_width)
+            max_height = _positive_dimension(max_height, default_max_height)
+            if max_width is None or max_height is None:
+                thumbnails.append(None)
+                continue
+
+            try:
+                stream = open_blob_descriptor_stream(raw_value, 
blob_reader_registry)
+                try:
+                    thumbnails.append(
+                        _decode_media_thumbnail(
+                            stream, image_format, max_width, max_height
+                        )
+                    )
+                finally:
+                    stream.close()
+            except ImportError:
+                raise
+            except Exception as e:
+                logger.warning("Failed to decode media thumbnail: %s", e)
+                thumbnails.append(None)
+
+        return pa.array(thumbnails, type=pa.binary())
+
+    return media_thumbnail
+
+
+def _coerce_vector(value: Any) -> list[float] | None:
+    if value is None or not isinstance(value, list):
+        return None
+
+    vector = []
+    for item in value:
+        if isinstance(item, bool) or not isinstance(item, (int, float)):
+            return None
+        item = float(item)
+        if not math.isfinite(item):
+            return None
+        vector.append(item)
+    return vector
+
+
+def _make_vector_from_json():
+    def vector_from_json(values):
+        try:
+            import pyarrow as pa
+        except ImportError as e:
+            raise ImportError("pyarrow is required to return vector_from_json 
results") from e
+
+        vectors = []
+        for raw_value in values.to_pylist():
+            if raw_value is None:
+                vectors.append(None)
+                continue
+
+            try:
+                parsed = json.loads(raw_value)
+                vectors.append(_coerce_vector(parsed))
+            except Exception:
+                vectors.append(None)
+
+        return pa.array(vectors, type=pa.list_(pa.float32()))
+
+    return vector_from_json
+
+
+def _make_vector_to_json():
+    def vector_to_json(values):
+        try:
+            import pyarrow as pa
+        except ImportError as e:
+            raise ImportError("pyarrow is required to return vector_to_json 
results") from e
+
+        encoded = []
+        for raw_value in values.to_pylist():
+            vector = _coerce_vector(raw_value)
+            encoded.append(_json_dumps(vector) if vector is not None else None)
+
+        return pa.array(encoded, type=pa.string())
+
+    return vector_to_json
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index f6e2a6e..8cddde0 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -18,7 +18,7 @@
 use std::collections::HashMap;
 use std::sync::Arc;
 
-use arrow::datatypes::DataType as ArrowDataType;
+use arrow::datatypes::{DataType as ArrowDataType, Field as ArrowField};
 use arrow::pyarrow::{FromPyArrow, ToPyArrow};
 use datafusion::catalog::CatalogProvider;
 use datafusion::logical_expr::{Signature, TypeSignature, Volatility};
@@ -129,12 +129,63 @@ pub struct PySQLContext {
 }
 
 impl PySQLContext {
-    fn register_video_builtins(&self, py: Python<'_>) -> PyResult<()> {
+    fn vector_float32_type() -> ArrowDataType {
+        ArrowDataType::List(Arc::new(ArrowField::new(
+            "item",
+            ArrowDataType::Float32,
+            true,
+        )))
+    }
+
+    fn register_multimodal_builtins(&self, py: Python<'_>) -> PyResult<()> {
         let functions = py.import("pypaimon_rust.functions")?;
         let blob_reader_registry = Py::new(
             py,
             PyBlobReaderRegistry::new(self.inner.blob_reader_registry()),
         )?;
+
+        let media_info_blob_reader_registry = 
blob_reader_registry.clone_ref(py);
+        let media_info_func = functions
+            .getattr("_make_media_info")?
+            .call1((media_info_blob_reader_registry,))?
+            .unbind();
+        let media_info_udf = build_python_scalar_udf(
+            "media_info".to_string(),
+            media_info_func,
+            ArrowDataType::Utf8,
+            Signature::exact(vec![ArrowDataType::Binary], 
Volatility::Volatile),
+        );
+        self.inner.ctx().register_udf(media_info_udf);
+
+        let thumbnail_blob_reader_registry = 
blob_reader_registry.clone_ref(py);
+        let thumbnail_func = functions
+            .getattr("_make_media_thumbnail")?
+            .call1(("PNG", thumbnail_blob_reader_registry))?
+            .unbind();
+        let thumbnail_signature = Signature::one_of(
+            vec![
+                TypeSignature::Exact(vec![ArrowDataType::Binary]),
+                TypeSignature::Exact(vec![
+                    ArrowDataType::Binary,
+                    ArrowDataType::Int32,
+                    ArrowDataType::Int32,
+                ]),
+                TypeSignature::Exact(vec![
+                    ArrowDataType::Binary,
+                    ArrowDataType::Int64,
+                    ArrowDataType::Int64,
+                ]),
+            ],
+            Volatility::Volatile,
+        );
+        let thumbnail_udf = build_python_scalar_udf(
+            "media_thumbnail".to_string(),
+            thumbnail_func,
+            ArrowDataType::Binary,
+            thumbnail_signature,
+        );
+        self.inner.ctx().register_udf(thumbnail_udf);
+
         let snapshot_blob_reader_registry = blob_reader_registry.clone_ref(py);
         let snapshot_func = functions
             .getattr("_make_video_snapshot")?
@@ -174,16 +225,44 @@ impl PySQLContext {
             frame_signature,
         );
         self.inner.ctx().register_udf(frame_udf);
+
+        let vector_from_json_func = functions
+            .getattr("_make_vector_from_json")?
+            .call0()?
+            .unbind();
+        let vector_from_json_signature = Signature::one_of(
+            vec![
+                TypeSignature::Exact(vec![ArrowDataType::Utf8]),
+                TypeSignature::Exact(vec![ArrowDataType::LargeUtf8]),
+            ],
+            Volatility::Immutable,
+        );
+        let vector_from_json_udf = build_python_scalar_udf(
+            "vector_from_json".to_string(),
+            vector_from_json_func,
+            Self::vector_float32_type(),
+            vector_from_json_signature,
+        );
+        self.inner.ctx().register_udf(vector_from_json_udf);
+
+        let vector_to_json_func = 
functions.getattr("_make_vector_to_json")?.call0()?.unbind();
+        let vector_to_json_udf = build_python_scalar_udf(
+            "vector_to_json".to_string(),
+            vector_to_json_func,
+            ArrowDataType::Utf8,
+            Signature::new(TypeSignature::Any(1), Volatility::Immutable),
+        );
+        self.inner.ctx().register_udf(vector_to_json_udf);
         Ok(())
     }
 
-    fn warn_video_builtin_registration_failure(py: Python<'_>, err: PyErr) {
+    fn warn_multimodal_builtin_registration_failure(py: Python<'_>, err: 
PyErr) {
         if let Ok(warnings) = py.import("warnings") {
             let category = py.get_type::<PyRuntimeWarning>();
             let _ = warnings.call_method1(
                 "warn",
                 (
-                    format!("video built-ins could not be registered: {err}"),
+                    format!("multimodal built-ins could not be registered: 
{err}"),
                     category,
                 ),
             );
@@ -198,8 +277,8 @@ impl PySQLContext {
         let ctx = Self {
             inner: SQLContext::new(),
         };
-        if let Err(err) = ctx.register_video_builtins(py) {
-            Self::warn_video_builtin_registration_failure(py, err);
+        if let Err(err) = ctx.register_multimodal_builtins(py) {
+            Self::warn_multimodal_builtin_registration_failure(py, err);
         }
         Ok(ctx)
     }
diff --git a/bindings/python/tests/test_datafusion.py 
b/bindings/python/tests/test_datafusion.py
index 5430b2a..e68698c 100644
--- a/bindings/python/tests/test_datafusion.py
+++ b/bindings/python/tests/test_datafusion.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import io
+import json
 import os
 import struct
 import sys
@@ -65,11 +66,14 @@ def write_sample_video(
             container.mux(packet)
 
 
-def sample_image_bytes() -> bytes:
+def sample_image_bytes(
+    size: tuple[int, int] = (32, 32),
+    color: tuple[int, int, int] = (40, 120, 220),
+) -> bytes:
     image_module = pytest.importorskip("PIL.Image")
 
     output = io.BytesIO()
-    image = image_module.new("RGB", (32, 32), color=(40, 120, 220))
+    image = image_module.new("RGB", size, color=color)
     image.save(output, format="PNG")
     return output.getvalue()
 
@@ -86,16 +90,24 @@ def 
test_video_snapshot_builtin_registered_on_context_init():
         """
         SELECT
             video_snapshot(CAST(NULL AS BYTEA)) AS cover_png,
-            video_frame(CAST(NULL AS BYTEA), 0) AS frame_png
+            video_frame(CAST(NULL AS BYTEA), 0) AS frame_png,
+            media_info(CAST(NULL AS BYTEA)) AS media_info_json,
+            media_thumbnail(CAST(NULL AS BYTEA)) AS thumbnail_png,
+            vector_from_json(CAST(NULL AS STRING)) AS vector_value,
+            vector_to_json(vector_from_json('[1.0, 2.5]')) AS vector_json
         """
     )
     table = pa.Table.from_batches(batches)
 
     assert table["cover_png"].to_pylist() == [None]
     assert table["frame_png"].to_pylist() == [None]
+    assert table["media_info_json"].to_pylist() == [None]
+    assert table["thumbnail_png"].to_pylist() == [None]
+    assert table["vector_value"].to_pylist() == [None]
+    assert json.loads(table["vector_json"].to_pylist()[0]) == [1.0, 2.5]
 
 
-def test_sql_context_survives_video_builtins_registration_failure(monkeypatch):
+def 
test_sql_context_survives_multimodal_builtins_registration_failure(monkeypatch):
     monkeypatch.setitem(
         sys.modules,
         "pypaimon_rust.functions",
@@ -104,7 +116,7 @@ def 
test_sql_context_survives_video_builtins_registration_failure(monkeypatch):
 
     with pytest.warns(
         RuntimeWarning,
-        match="video built-ins could not be registered",
+        match="multimodal built-ins could not be registered",
     ):
         ctx = SQLContext()
 
@@ -336,6 +348,122 @@ def test_video_frame_accepts_frame_index():
         ctx.sql("DROP TEMPORARY TABLE paimon.default.videos")
 
 
+def test_media_info_returns_json_for_image_and_video():
+    with tempfile.TemporaryDirectory() as warehouse:
+        video_path = Path(warehouse) / "sample.mp4"
+        write_sample_video(video_path)
+
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.register_batch(
+            "paimon.default.media",
+            pa.record_batch(
+                [
+                    [1, 2],
+                    pa.array(
+                        [
+                            sample_image_bytes(size=(48, 24)),
+                            video_path.read_bytes(),
+                        ],
+                        type=pa.binary(),
+                    ),
+                ],
+                names=["id", "content"],
+            ),
+        )
+
+        batches = ctx.sql(
+            """
+            SELECT id, media_info(content) AS info_json
+            FROM paimon.default.media
+            ORDER BY id
+            """
+        )
+        rows = pa.Table.from_batches(batches).to_pylist()
+        image_info = json.loads(rows[0]["info_json"])
+        video_info = json.loads(rows[1]["info_json"])
+
+        assert image_info["media_type"] == "image"
+        assert image_info["format"] == "png"
+        assert image_info["width"] == 48
+        assert image_info["height"] == 24
+
+        assert video_info["media_type"] == "video"
+        assert video_info["width"] == 32
+        assert video_info["height"] == 32
+        assert video_info["has_audio"] is False
+
+        ctx.sql("DROP TEMPORARY TABLE paimon.default.media")
+
+
+def test_media_thumbnail_handles_image_and_video():
+    image_module = pytest.importorskip("PIL.Image")
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        video_path = Path(warehouse) / "sample.mp4"
+        write_sample_video(video_path)
+
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.register_batch(
+            "paimon.default.media",
+            pa.record_batch(
+                [
+                    [1, 2],
+                    pa.array(
+                        [
+                            sample_image_bytes(size=(64, 32)),
+                            video_path.read_bytes(),
+                        ],
+                        type=pa.binary(),
+                    ),
+                ],
+                names=["id", "content"],
+            ),
+        )
+
+        batches = ctx.sql(
+            """
+            SELECT
+                id,
+                media_thumbnail(content, 16, 16) AS thumbnail_png,
+                media_thumbnail(content, -1, 16) AS invalid_thumbnail_png
+            FROM paimon.default.media
+            ORDER BY id
+            """
+        )
+        rows = pa.Table.from_batches(batches).to_pylist()
+
+        for row in rows:
+            assert row["thumbnail_png"].startswith(PNG_SIGNATURE)
+            thumbnail = image_module.open(io.BytesIO(row["thumbnail_png"]))
+            assert thumbnail.width <= 16
+            assert thumbnail.height <= 16
+            assert row["invalid_thumbnail_png"] is None
+
+        ctx.sql("DROP TEMPORARY TABLE paimon.default.media")
+
+
+def test_vector_json_bridge_functions():
+    ctx = SQLContext()
+
+    batches = ctx.sql(
+        """
+        SELECT
+            vector_from_json('[1.0, 2.5, -3]') AS vector_value,
+            vector_from_json('not json') AS invalid_json,
+            vector_from_json('[true]') AS invalid_value,
+            vector_to_json(vector_from_json('[1, 2.5]')) AS vector_json
+        """
+    )
+    row = pa.Table.from_batches(batches).to_pylist()[0]
+
+    assert row["vector_value"] == [1.0, 2.5, -3.0]
+    assert row["invalid_json"] is None
+    assert row["invalid_value"] is None
+    assert json.loads(row["vector_json"]) == [1.0, 2.5]
+
+
 def test_query_simple_table_via_catalog_provider():
     catalog = PaimonCatalog({"warehouse": WAREHOUSE})
     ctx = SessionContext()
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6ae3b62..e983274 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -722,6 +722,57 @@ When the following conditions are met, `COUNT(*)` 
retrieves exact row counts dir
 - No LIMIT clause
 - Filter predicates only involve partition columns (Exact level)
 
+## Python Multimodal Helper Functions
+
+When you use `pypaimon_rust.datafusion.SQLContext`, the Python binding 
registers a small set of scalar helper functions for BLOB-backed media and 
vector workflows. These helpers are Python-binding built-ins; they are not 
registered by the Rust `paimon_datafusion::SQLContext`.
+
+Media helpers require the optional Python media dependencies:
+
+```shell
+pip install "pypaimon-rust[video]"
+```
+
+| Function | Return Type | Description |
+|---|---|---|
+| `media_info(blob)` | STRING | JSON metadata for image, video, or audio input 
|
+| `media_thumbnail(blob)` | BINARY | PNG thumbnail, using a default 320x320 
bounding box |
+| `media_thumbnail(blob, max_width, max_height)` | BINARY | PNG thumbnail 
constrained to the given dimensions |
+| `video_snapshot(blob)` | BINARY | PNG frame near timestamp 0ms |
+| `video_snapshot(blob, timestamp_ms)` | BINARY | PNG frame near the given 
timestamp |
+| `video_frame(blob, frame_index)` | BINARY | PNG frame by zero-based decoded 
frame index |
+| `vector_from_json(json)` | `List<Float32>` | Converts a JSON float array 
string into an Arrow float vector |
+| `vector_to_json(vector)` | STRING | Converts an Arrow float vector back to a 
JSON array string |
+
+Invalid, NULL, unsupported, or undecodable media inputs return SQL `NULL`. 
Media functions read either inline bytes or BLOB descriptor bytes when the 
`SQLContext` has a registered Paimon catalog that can resolve the descriptor.
+
+Example:
+
+```sql
+SELECT
+    id,
+    media_info(content) AS info_json,
+    media_thumbnail(content, 160, 90) AS preview_png,
+    video_frame(content, 10) AS frame_png
+FROM paimon.my_db.assets;
+```
+
+Use `vector_from_json` to bridge JSON-encoded embeddings into lateral vector 
search queries:
+
+```sql
+WITH queries AS (
+    SELECT id, vector_from_json(embedding_json) AS embedding
+    FROM paimon.my_db.query_embeddings
+)
+SELECT q.id AS query_id, r.id AS result_id
+FROM queries q
+CROSS JOIN LATERAL vector_search(
+    'paimon.my_db.items',
+    'embedding',
+    q.embedding,
+    10
+) AS r;
+```
+
 ## Vector Search
 
 Paimon supports approximate nearest neighbor (ANN) vector search via the 
Lumina vector index. The `vector_search` table-valued function is registered as 
a UDTF on the DataFusion session context.

Reply via email to