Search by

pushinbr / pam-native-media

DavidWilliamBalbino

Native media inspection, image processing, video transcoding, thumbnails and embedded camera capture for PAM Native.

Package info

github.com/push-in/pam-native-media

Language:Kotlin

Type:pam-native-plugin

pkg:composer/pushinbr/pam-native-media

Statistics

Installs: 265

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

v0.6.0 2026-10-15 00:22 UTC

This package is auto-updated.

Last update: 2026-10-15 00:23:09 UTC


README

PAM Native Media

Inspect and prepare media without loading whole files into PHP.

Read metadata and generate thumbnails through native codecs with sandboxed paths and bounded results.

Latest version CI PHP Android iOS

Documentation · Quick start · What you can build · PAM ecosystem · Issues

Why PAM Native Media

Read metadata and generate thumbnails through native codecs with sandboxed paths and bounded results. The public API is strictly typed for PHP 8.5; expensive or frame-sensitive work stays in Rust or the platform SDK instead of crossing the application boundary every frame.

Best for A focused capability you can add to any PAM Native application
Native path MediaMetadataRetriever · AVFoundation
Application model Composer package + generated native integration
Design rule Independent module; no feed, vertical, or application template bundled

What you can build

  • Gallery and attachment pickers
  • Upload preparation and validation
  • Fast thumbnail pipelines for local media libraries

Quick start

Already have a PAM Native project? Add only this capability:

pam composer require pushinbr/pam-native-media
pam doctor --fix

New to PAM? Follow the five-minute PAM Native setup once, then return here. Your application stays a normal Composer project with a committed lockfile.

See it in action

Inspect images, audio and video and generate correctly oriented thumbnails without loading media bytes into PHP memory.

pam add media
pam doctor
use Pam\Native\Media\{FlipDirection, ImageFormat, ImageInfo, ImageResult, Media, ResizeMode, Thumbnail, ThumbnailResult, TranscodeResult, VideoPreset};

// Upload-ready MP4 (H.264 Main / AAC-LC, bounded size, 2 s keyframes, fast start)
$task = Media::transcode('captures/clip.mov')
    ->to('outbox/clip.mp4')
    ->preset(VideoPreset::Chat720p)
    ->maxBitrate(1_200_000)
    ->fastStart()
    ->progress(fn (float $p) => $this->encoding = $p)
    ->run(fn (TranscodeResult $video) => $this->send($video->path), fn (string $error) => $this->fail($error));
// $task->cancel();

// Image pipeline: EXIF orientation first, then operations in call order
Media::image('captures/photo.jpg')
    ->resize(1600, 1600, ResizeMode::Contain)
    ->onlyScaleDown()
    ->crop(0, 0, 1600, 900)
    ->rotate(90)
    ->flip(FlipDirection::Horizontal)
    ->format(ImageFormat::Webp, 80)
    ->save('outbox/photo.webp', fn (ImageResult $image) => ...);

Media::image('captures/photo.jpg')->probe(fn (ImageInfo $info) => [$info->width, $info->height, $info->orientation]);

// Many thumbnails in one native call (sandbox paths, or HTTPS URLs for remote videos)
Media::thumbnails([
    Thumbnail::make('captures/clip.mov', 'thumbs/clip.jpg')->size(320, 320)->at(1000),
    Thumbnail::make('https://cdn.example.com/v.mp4', 'thumbs/v.jpg'),
], fn (array $results) => ...); // list<ThumbnailResult>, request order, per-item error

// Unchanged 0.3 API
$media = new Media();
$media->probe('media/clip.mp4', function (?Pam\Native\Media\MediaInfo $info, ?string $error): void {});
$media->thumbnail('media/clip.mp4', 'thumbs/clip.jpg', 640, 360, function (?string $path, ?string $error): void {});

VideoPreset: Compact480p (≤854×480, 0.8 Mbps), Chat720p (≤1280×720, 1.5 Mbps), Chat1080p (≤1920×1080, 2.5 Mbps), Adaptive (1080p up to 3 min, then 720p). Presets never upscale, keep the display orientation and aspect ratio, tone-map HDR to SDR, convert mono audio to stereo and resample unusual rates to 48 kHz. ResizeMode::Contain|Cover|Stretch accept 0 for one automatic dimension; onlyScaleDown() never enlarges (Cover then keeps the box aspect ratio). Decoding subsamples large photos to the size actually needed and caps decoded pixels, so PHP never holds pixel data. Outputs are written atomically.

Android uses Media3 Transformer 1.10.1, platform codecs and ExifInterface 1.4.2; Android paths resolve inside the PAM file sandbox (filesDir/pam-files, the FileReference::$path space). iOS (0.5+) implements the same API: transcode() re-encodes with AVAssetReader/AVAssetWriter to H.264 Main 4.1 / AAC-LC with the Android preset targets (dimensions, bitrate tiers, 2 s keyframes, stereo 44.1 kHz audio, BT.709 SDR) and writes moov first for fastStart(); display orientation is kept as the track transform. image() decodes through ImageIO (EXIF-oriented, subsampled, capped at 48 MP) and applies the same resize/crop/rotate/flip geometry as Android; ImageFormat::Webp needs an iOS version whose ImageIO can encode WebP and fails with a clear message otherwise. thumbnails() batches and HTTPS video thumbnails are supported. iOS paths resolve inside Application Support/pam-files (the FileReference::$path space). The stable Objective-C entry point PamMediaTranscoding lets pushinbr/pam-native-background-transfer 0.4+ transcode inside its pipelines. The iOS implementation has not been validated on a device yet; see ios/Tests/MediaModuleTests.swift. Images honor EXIF orientation and video thumbnails honor track transforms.

Other PAM plugins can reuse the transcoder through the stable JVM entry point dev.pam.media.MediaTranscoding.transcode(context, source, destination, optionsJson, cancelled, progress); pushinbr/pam-native-background-transfer uses it for ->transcode().

On macOS, run the focused sizing and sandbox checks with swiftc ios/Sources/MediaThumbnailSizing.swift ios/Sources/MediaSandboxPath.swift tests/ios-thumbnail/main.swift -o /tmp/pam-media-ios-check && /tmp/pam-media-ios-check.

Embedded camera

CameraView renders a lifecycle-aware native camera preview. Capture commands are revision based, so a declarative re-render never repeats an operation:

$camera = CameraView::make()
    ->facing(CameraFacing::Back)
    ->mode(CameraMode::Photo)
    ->flash(CameraFlashMode::Off)
    ->captureRevision($photoRevision)
    ->recordRevision($recordRevision)
    ->stopRevision($stopRevision)
    ->onEvent(function (CameraEventKind $event, ?CameraCapture $capture, string $message): void {
        if ($event === CameraEventKind::Captured && $capture !== null) {
            // $capture->path is relative to the app-owned PAM file sandbox.
        }
    });

The view supports front/back lenses, explicit photo/video mode, off/on/auto photo flash, torch while recording, optional audio, bounded recording duration and explicit stop. Photo and Video bind only the CameraX/AVFoundation outputs they need. PhotoVideo binds photo and video together for a shutter that takes a photo on tap and records while held; devices that reject the combined stream configuration fall back to the photo pipeline and a record command then reports Failure. Android uses CameraX's texture-compatible preview pipeline so controls can be composited above the camera and reactive updates do not abandon its surface. The preview host is touch-transparent; apps retain ownership of declarative shutter, mode, flash, and lens controls. Apps must request camera/microphone permission before enabling the view. Both platforms persist captures under the PAM file sandbox and return relative paths. Construct a FileReference from the capture metadata to preview, edit, upload, or delete the file with the standard PAM APIs.

Optional recording zoom runs entirely on the native camera:

$camera = CameraView::make()
    ->mode(CameraMode::Video)
    ->recordingZoom(2.8, 1600)
    ->maxDuration(3)
    ->recordRevision($recordRevision)
    ->stopRevision($stopRevision);

The ramp starts at RecordingStarted, from 1× to the requested target over the specified milliseconds. The camera's zoom range bounds the target; stopping, disabling, changing lenses/modes or releasing the view cancels and resets zoom. The default target 1.0 and duration 0 leave zoom disabled. Targets must be finite and at least 1.0; durations accept 0–600000 milliseconds, with zero disabling the animation. Direct media.camera hosts use the additive zoomTarget (decimal) and zoomDurationMillis (integer) properties. Changing these values affects the next recording. Existing camera event integers stay unchanged. Android uses CameraX with a native animator, and iOS uses the AVFoundation zoom ramp; no animation frames cross the PHP bridge.

Pinch zoom, zoom reset and double tap

Gestures are opt-in and handled natively; no gesture frames cross the bridge:

$camera = CameraView::make()
    ->pinchZoom()                       // pinch on the preview
    ->maxZoom(8.0)                      // default 16; the lens maximum still applies
    ->zoomResetRevision($zoomReset)     // bump to return to 1x
    ->doubleTapFlip()                   // double tap switches back/front lens
    ->onEvent(function (CameraEventKind $event, ?CameraCapture $capture, string $message, ?CameraLens $lens): void {
        if ($event === CameraEventKind::Ready) {
            // $lens->facing, $lens->minZoom, $lens->maxZoom (effective, maxZoom applied)
        }
    });
  • Pinch (pinchZoom, default off): zoom = ratio at gesture start × scale accumulated since the start, clamped to the lens minimum and min(maxZoom, lens maximum). Android uses ScaleGestureDetector with CameraX setZoomRatio; iOS uses UIPinchGestureRecognizer with videoZoomFactor. A pinch during a recordingZoom() ramp cancels the ramp and keeps the user's zoom (it is not reset to 1× when that recording stops).
  • Reset (zoomResetRevision): each change after the first render sets zoom to 1× clamped to the effective range and cancels a running recording ramp.
  • Ready (CameraEventKind::Ready, 1) now carries a CameraLens with the facing and the effective minZoom/maxZoom. It is sent whenever the camera is bound (again after a lens or mode change).
  • Double tap (CameraEventKind::DoubleTap, 7): reported when doubleTapFlip() or doubleTapEvents() is on. With doubleTapFlip() the view switches lens natively (not while recording, which would end the clip); the event then reports the new facing with minZoom/maxZoom 0, and the new range follows in Ready. The host's facing() value takes over again on its next change.
  • Touches pass through the camera view unless pinchZoom, doubleTapFlip or doubleTapEvents is on. Detectors/recognizers exist only while the view is enabled and one of them is on, and are dropped on disable and release.

Direct media.camera hosts use the additive pinchZoom, doubleTapFlip, doubleTapEvents (flags), maxZoom (decimal) and zoomResetRevision (integer) properties; events gain facing (integer) and minZoom/maxZoom (decimals). onEvent handlers with three parameters keep working.

Platform support: Android API 26+, iOS 15+, PAM Native >=1.0.35 <2.0.0.

What installation does

pam add media (or pam composer require pushinbr/pam-native-media followed by pam doctor --fix) resolves the official compatible package, performs a non-mutating Composer preflight, updates the normal composer.json and composer.lock, refreshes generated native integration when required, and leaves the project ready for pam doctor validation. The package is a PAM Native plugin (module media, view media.camera); nothing is added to pam-native.json.

Use pam packages to inspect availability and pam remove media to uninstall the capability safely. Direct Composer commands are an advanced interoperability path; PAM is the supported application workflow.

Android

Merged permissions: CAMERA and RECORD_AUDIO (only CameraView uses them; request PermissionKind::Camera/Microphone before enabling the view). Dependencies: CameraX 1.6.1 (camera-camera2, camera-lifecycle, camera-view, camera-video), Media3 1.10.1 (media3-common, media3-effect, media3-transformer) and ExifInterface 1.4.2.

iOS

Frameworks AVFoundation, CoreGraphics, CoreMedia, CoreVideo, ImageIO, UniformTypeIdentifiers. Usage strings merged into Info.plist: NSCameraUsageDescription = "Capture photos and videos." and NSMicrophoneUsageDescription = "Record audio with videos." Other plugins that declare the same keys (pam-native-webrtc, pam-native-camera) use the same strings, because the CLI rejects conflicting values.

A real example: Zé Chat

Zé Chat compresses every photo before it enters the outbox, like its React Native predecessor (optimizeImageToWebp): EXIF orientation baked in, longest side at most 1920 px and never upscaled, lossy WebP 82. GIFs and any failure keep the original file:

use Pam\Native\FileReference;
use Pam\Native\Media\{ImageFormat, ImageInfo, ImageResult, Media, ResizeMode};

public static function compressImage(FileReference $file, Closure $done): void
{
    if (!str_starts_with($file->mimeType, 'image/') || $file->mimeType === 'image/gif') {
        $done($file, 0, 0);
        return;
    }
    $destination = 'zechat-outbox/'.bin2hex(random_bytes(12)).'.webp';
    try {
        Media::image($file->path)
            ->resize(1920, 1920, ResizeMode::Contain)
            ->onlyScaleDown()
            ->format(ImageFormat::Webp, 82)
            ->save(
                $destination,
                fn (ImageResult $r) => $done(new FileReference($r->path, 'photo.webp', ImageFormat::Webp->mimeType(), $r->bytes), $r->width, $r->height),
                fn (string $_error) => $done($file, 0, 0),
            );
    } catch (InvalidArgumentException) {   // a path outside the sandbox
        $done($file, 0, 0);
    }
}

// Display size after EXIF orientation (ImageInfo already swaps 90°/270°).
Media::image($file->path)->probe(fn (ImageInfo $i) => $done($i->width, $i->height), fn () => $done(0, 0));

Videos are re-encoded inside the durable upload with pam-native-background-transfer's ->transcode(VideoPreset::Adaptive, fallbackToOriginal: true), which calls this package's transcoder natively. A runnable minimal app is in example/.

API reference

All classes live in Pam\Native\Media. Native calls return the module request id (int).

Media

Method Description
Media::transcode(string $source): PendingTranscode Video transcode builder.
Media::image(string $source): PendingImage Image pipeline builder.
Media::thumbnails(list<Thumbnail> $requests, Closure(list<ThumbnailResult>) $then) 1–100 thumbnails in one call, results in request order.
(new Media())->probe(string $path, Closure(?MediaInfo, ?string) $complete) Type, size, dimensions, duration, rotation.
(new Media())->thumbnail(string $source, string $destination, int $maxWidth, int $maxHeight, Closure(?string, ?string) $complete, ThumbnailFormat $format = Jpeg, int $quality = 85, int $timeMillis = 0) One thumbnail (0.3 API).

PendingTranscode / TranscodeTask / TranscodeResult

to(string $destination) (required, must differ from the source), preset(VideoPreset) (default Adaptive), maxBitrate(int $bitsPerSecond) (100 kbps–50 Mbps), fastStart(bool = true), withoutAudio(), progress(Closure(float)) (0.0–1.0), options(), run(Closure(TranscodeResult) $then, ?Closure(string) $failed = null): TranscodeTask. TranscodeTask: cancel(), finished(). TranscodeResult (readonly): path, mimeType, width, height, durationMillis, bytes, bitrate, fastStart.

PendingImage / ImageResult / ImageInfo

resize(int $width, int $height, ResizeMode $mode = Contain) (1–16384, 0 = automatic), onlyScaleDown(bool = true), crop(int $x, int $y, int $width, int $height), rotate(int $degrees = 90) (multiples of 90, negative = counter-clockwise), flip(FlipDirection = Horizontal), format(ImageFormat, int $quality = 85) (1–100; default JPEG 85), payload(string $destination), save(string $destination, Closure(ImageResult) $then, ?Closure(string) $failed = null), probe(Closure(ImageInfo) $then, ?Closure(string) $failed = null). ImageResult: path, width, height, bytes, mimeType. ImageInfo: width, height (display, after orientation), storedWidth, storedHeight, orientation (ExifOrientation), mimeType, bytes.

Thumbnail / ThumbnailResult

Thumbnail::make(string $source, string $destination) (sandbox path or HTTPS video URL), size(int $maxWidth, int $maxHeight) (1–8192), at(int $timeMillis), format(ThumbnailFormat, int $quality = 80), source(), toWire(). ThumbnailResult: source, path (null on failure), width, height, error, succeeded().

CameraView (Renderable, immutable)

make(), facing(CameraFacing) (default Back), mode(CameraMode) (default Photo), flash(CameraFlashMode), enabled(bool = true), captureRevision(int), recordRevision(int), stopRevision(int), maxDuration(int $seconds) (1–600, default 60), audio(bool = true), recordingZoom(float $target, int $durationMillis = 1600), pinchZoom(bool = true), maxZoom(float) (finite, >= 1, default 16), zoomResetRevision(int), doubleTapFlip(bool = true), doubleTapEvents(bool = true), onEvent(Closure(CameraEventKind, ?CameraCapture, string $message, ?CameraLens $lens)), toElement() (a CustomView of kind media.camera). Each revision bump runs its command once. CameraCapture (readonly): path, mimeType, width, height, durationMillis. CameraLens (readonly, Ready/DoubleTap only): facing, minZoom, maxZoom (0 when not known yet).

Value types and enums (int-backed)

Type Members
MediaInfo kind, mimeType, bytes, width, height, durationMillis, orientationDegrees
MediaKind Image = 1, Audio = 2, Video = 3, Unknown = 4
VideoPreset Compact480p = 1, Chat720p = 2, Chat1080p = 3, Adaptive = 4
ResizeMode Contain = 1, Cover = 2, Stretch = 3
ImageFormat Jpeg = 1, Png = 2, Webp = 3; mimeType()
ThumbnailFormat Jpeg = 1, Png = 2, Webp = 3
FlipDirection Horizontal = 1, Vertical = 2
ExifOrientation EXIF values Normal = 1 … Rotate270 = 8; degrees(), swapsDimensions()
CameraFacing Back = 1, Front = 2
CameraMode Photo = 1, Video = 2, PhotoVideo = 3
CameraFlashMode Off = 1, On = 2, Auto = 3
CameraEventKind Ready = 1, Captured, RecordingStarted, RecordingStopped, PermissionDenied, Failure = 6, DoubleTap = 7
MediaPath assert(), assertSource() sandbox path validation

Errors

Builders throw InvalidArgumentException for absolute or traversal paths, invalid HTTPS URLs, out-of-range sizes, qualities, bitrates and thumbnail times, crop rectangles outside the image, rotations that are not multiples of 90, a batch outside 1–100 requests, non-Thumbnail batch items and a transcode destination equal to its source. run() without to() throws LogicException. Native failures go to the $failed/$complete callbacks (or ThumbnailResult::$error) and never throw.

Tests

composer test runs the PHP contract suite. Android JVM tests (android/src/test: resize geometry, fast-start rewriting, options, recording and gesture zoom math) and instrumented tests (android/src/androidTest: EXIF pipeline, thumbnails, Media3 transcode, cross-plugin entry point) run from a PAM Android host that includes this plugin with testDebugUnitTest / connectedDebugAndroidTest. JVM tests require JUnit 4 and the JVM org.json implementation on the test classpath (the Android SDK JSON stub cannot execute parser tests).

Production checklist

  • Request camera and microphone permissions before enabling capture.
  • Move or upload captures using their sandbox-relative FileReference.
  • Bound recording duration, thumbnail dimensions, and retained media.
  • Run pam doctor, pam test, and a signed release build on every supported platform.
  • Exercise denial, cancellation, backgrounding, process restart, and offline behavior before release.

Troubleshooting

  • A path is rejected: use app-relative paths and never absolute/traversal paths.
  • Preview works but capture fails: verify permissions and available device storage.
  • Combined streams fail on a device: enable only the outputs required by the selected mode.
  • Native integration is stale: run pam doctor --fix, rebuild the native host, and inspect the first reported diagnostic.

Compatibility and support

pushinbr/pam-native-media pushinbr/pam-native Android iOS
0.5.x >=1.0.35 <2.0.0 (tested with 1.14.x) API 26+ 15+ (transcode, image pipeline, batches)
0.4.x >=1.0.35 <2.0.0 API 26+ Probe, thumbnails and camera only

This package targets PAM Native >=1.0.35 <2.0.0, Android API 26+, and iOS 15+ unless a platform-specific section above states a stricter requirement. Platform SDKs, credentials, entitlements, physical hardware, and store configuration remain application responsibilities.

Security vulnerabilities should be reported through the repository security policy or GitHub private vulnerability reporting, not a public issue.