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 c056ef0  Add video frame SQL UDF (#465)
c056ef0 is described below

commit c056ef07d6722c7b01b14fc696eb4cfd44b471c3
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 7 13:08:37 2026 +0800

    Add video frame SQL UDF (#465)
---
 bindings/python/python/pypaimon_rust/functions.py | 88 ++++++++++++++++++++---
 bindings/python/src/context.rs                    | 44 ++++++++----
 bindings/python/tests/test_datafusion.py          | 63 ++++++++++++++--
 3 files changed, 170 insertions(+), 25 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/functions.py 
b/bindings/python/python/pypaimon_rust/functions.py
index 9a3b4cf..2460a36 100644
--- a/bindings/python/python/pypaimon_rust/functions.py
+++ b/bindings/python/python/pypaimon_rust/functions.py
@@ -117,15 +117,44 @@ def _decode_video_snapshot(
             break
 
         if candidate is not None:
-            try:
-                image = candidate.to_image()
-            except ImportError as e:
-                raise ImportError(
-                    "Pillow is required to encode video_snapshot images"
-                ) from e
-            output = io.BytesIO()
-            image.save(output, format=image_format)
-            return output.getvalue()
+            return _encode_video_frame(candidate, image_format)
+    return None
+
+
+def _encode_video_frame(frame, image_format: str) -> bytes:
+    try:
+        image = frame.to_image()
+    except ImportError as e:
+        raise ImportError("Pillow is required to encode video frame images") 
from e
+    output = io.BytesIO()
+    image.save(output, format=image_format)
+    return output.getvalue()
+
+
+def _decode_video_frame(
+    stream: BinaryIO,
+    image_format: str,
+    frame_index: int,
+) -> bytes | None:
+    try:
+        import av
+    except ImportError as e:
+        raise ImportError("PyAV is required to decode video frames") from e
+
+    with av.open(stream, mode="r") as container:
+        format_names = set((container.format.name or "").split(","))
+        if format_names & _STILL_IMAGE_FORMATS:
+            logger.debug(
+                "video_frame input is a still image format: %s",
+                container.format.name,
+            )
+            return None
+        if not container.streams.video:
+            return None
+
+        for index, frame in enumerate(container.decode(video=0)):
+            if index == frame_index:
+                return _encode_video_frame(frame, image_format)
     return None
 
 
@@ -177,3 +206,44 @@ def _make_video_snapshot(image_format: str = "PNG", 
blob_reader_registry=None):
         return pa.array(frames, type=pa.binary())
 
     return video_snapshot
+
+
+def _make_video_frame(image_format: str = "PNG", blob_reader_registry=None):
+    image_format = image_format.upper()
+
+    def video_frame(values, frame_indices):
+        try:
+            import pyarrow as pa
+        except ImportError as e:
+            raise ImportError("pyarrow is required to return video_frame 
results") from e
+
+        frames = []
+        raw_values = values.to_pylist()
+        frame_index_values = frame_indices.to_pylist()
+        if len(frame_index_values) != len(raw_values):
+            raise ValueError("video_frame index argument must have the same 
row count")
+
+        for raw_value, frame_index in zip(raw_values, frame_index_values):
+            if raw_value is None or frame_index is None:
+                frames.append(None)
+                continue
+
+            try:
+                frame_index = int(frame_index)
+                if frame_index < 0:
+                    frames.append(None)
+                    continue
+                stream = open_blob_descriptor_stream(raw_value, 
blob_reader_registry)
+                try:
+                    frames.append(_decode_video_frame(stream, image_format, 
frame_index))
+                finally:
+                    stream.close()
+            except ImportError:
+                raise
+            except Exception as e:
+                logger.warning("Failed to decode video frame: %s", e)
+                frames.append(None)
+
+        return pa.array(frames, type=pa.binary())
+
+    return video_frame
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 43b1fdb..f6e2a6e 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -129,17 +129,18 @@ pub struct PySQLContext {
 }
 
 impl PySQLContext {
-    fn register_video_snapshot_builtin(&self, py: Python<'_>) -> PyResult<()> {
+    fn register_video_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 func = functions
+        let snapshot_blob_reader_registry = blob_reader_registry.clone_ref(py);
+        let snapshot_func = functions
             .getattr("_make_video_snapshot")?
-            .call1(("PNG", blob_reader_registry))?
+            .call1(("PNG", snapshot_blob_reader_registry))?
             .unbind();
-        let signature = Signature::one_of(
+        let snapshot_signature = Signature::one_of(
             vec![
                 TypeSignature::Exact(vec![ArrowDataType::Binary]),
                 TypeSignature::Exact(vec![ArrowDataType::Binary, 
ArrowDataType::Int32]),
@@ -147,23 +148,42 @@ impl PySQLContext {
             ],
             Volatility::Volatile,
         );
-        let udf = build_python_scalar_udf(
+        let snapshot_udf = build_python_scalar_udf(
             "video_snapshot".to_string(),
-            func,
+            snapshot_func,
+            ArrowDataType::Binary,
+            snapshot_signature,
+        );
+        self.inner.ctx().register_udf(snapshot_udf);
+
+        let frame_func = functions
+            .getattr("_make_video_frame")?
+            .call1(("PNG", blob_reader_registry))?
+            .unbind();
+        let frame_signature = Signature::one_of(
+            vec![
+                TypeSignature::Exact(vec![ArrowDataType::Binary, 
ArrowDataType::Int32]),
+                TypeSignature::Exact(vec![ArrowDataType::Binary, 
ArrowDataType::Int64]),
+            ],
+            Volatility::Volatile,
+        );
+        let frame_udf = build_python_scalar_udf(
+            "video_frame".to_string(),
+            frame_func,
             ArrowDataType::Binary,
-            signature,
+            frame_signature,
         );
-        self.inner.ctx().register_udf(udf);
+        self.inner.ctx().register_udf(frame_udf);
         Ok(())
     }
 
-    fn warn_video_snapshot_registration_failure(py: Python<'_>, err: PyErr) {
+    fn warn_video_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_snapshot built-in could not be registered: 
{err}"),
+                    format!("video built-ins could not be registered: {err}"),
                     category,
                 ),
             );
@@ -178,8 +198,8 @@ impl PySQLContext {
         let ctx = Self {
             inner: SQLContext::new(),
         };
-        if let Err(err) = ctx.register_video_snapshot_builtin(py) {
-            Self::warn_video_snapshot_registration_failure(py, err);
+        if let Err(err) = ctx.register_video_builtins(py) {
+            Self::warn_video_builtin_registration_failure(py, err);
         }
         Ok(ctx)
     }
diff --git a/bindings/python/tests/test_datafusion.py 
b/bindings/python/tests/test_datafusion.py
index 236b130..5430b2a 100644
--- a/bindings/python/tests/test_datafusion.py
+++ b/bindings/python/tests/test_datafusion.py
@@ -82,13 +82,20 @@ def extract_rows(batches):
 def test_video_snapshot_builtin_registered_on_context_init():
     ctx = SQLContext()
 
-    batches = ctx.sql("SELECT video_snapshot(CAST(NULL AS BYTEA)) AS 
cover_png")
+    batches = ctx.sql(
+        """
+        SELECT
+            video_snapshot(CAST(NULL AS BYTEA)) AS cover_png,
+            video_frame(CAST(NULL AS BYTEA), 0) AS frame_png
+        """
+    )
     table = pa.Table.from_batches(batches)
 
     assert table["cover_png"].to_pylist() == [None]
+    assert table["frame_png"].to_pylist() == [None]
 
 
-def test_sql_context_survives_video_snapshot_registration_failure(monkeypatch):
+def test_sql_context_survives_video_builtins_registration_failure(monkeypatch):
     monkeypatch.setitem(
         sys.modules,
         "pypaimon_rust.functions",
@@ -97,7 +104,7 @@ def 
test_sql_context_survives_video_snapshot_registration_failure(monkeypatch):
 
     with pytest.warns(
         RuntimeWarning,
-        match="video_snapshot built-in could not be registered",
+        match="video built-ins could not be registered",
     ):
         ctx = SQLContext()
 
@@ -280,6 +287,55 @@ def test_video_snapshot_accepts_timestamp_ms():
         ctx.sql("DROP TEMPORARY TABLE paimon.default.videos")
 
 
+def test_video_frame_accepts_frame_index():
+    image_module = pytest.importorskip("PIL.Image")
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        video_path = Path(warehouse) / "sample.mp4"
+        write_sample_video(
+            video_path,
+            colors=((240, 40, 80), (40, 220, 80), (40, 80, 240)),
+        )
+        video_bytes = video_path.read_bytes()
+
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.register_batch(
+            "paimon.default.videos",
+            pa.record_batch(
+                [[1], pa.array([video_bytes], type=pa.binary())],
+                names=["id", "video"],
+            ),
+        )
+
+        batches = ctx.sql(
+            """
+            SELECT
+                video_frame(video, 0) AS first_png,
+                video_frame(video, CAST(1 AS BIGINT)) AS second_png,
+                video_frame(video, CAST(2 AS INT)) AS third_png,
+                video_frame(video, 3) AS beyond_duration_png,
+                video_frame(video, -1) AS negative_png
+            FROM paimon.default.videos
+            """
+        )
+        row = pa.Table.from_batches(batches).to_pylist()[0]
+
+        assert row["first_png"].startswith(PNG_SIGNATURE)
+        assert row["second_png"].startswith(PNG_SIGNATURE)
+        assert row["third_png"].startswith(PNG_SIGNATURE)
+        assert row["beyond_duration_png"] is None
+        assert row["negative_png"] is None
+
+        first_image = 
image_module.open(io.BytesIO(row["first_png"])).convert("RGB")
+        second_image = 
image_module.open(io.BytesIO(row["second_png"])).convert("RGB")
+        third_image = 
image_module.open(io.BytesIO(row["third_png"])).convert("RGB")
+        assert first_image.getpixel((16, 16)) != second_image.getpixel((16, 
16))
+        assert second_image.getpixel((16, 16)) != third_image.getpixel((16, 
16))
+
+        ctx.sql("DROP TEMPORARY TABLE paimon.default.videos")
+
+
 def test_query_simple_table_via_catalog_provider():
     catalog = PaimonCatalog({"warehouse": WAREHOUSE})
     ctx = SessionContext()
@@ -684,4 +740,3 @@ def test_list_databases_and_tables():
     assert "name" in field_names
     # simple_log_table is non-partitioned, so partition keys are empty.
     assert schema.partition_keys() == []
-

Reply via email to