diff --git a/include/libcamera/buffer.h b/include/libcamera/buffer.h
index 260a62e9e77e..d5d3dc90a096 100644
--- a/include/libcamera/buffer.h
+++ b/include/libcamera/buffer.h
@@ -59,6 +59,7 @@ private:
 	friend class BufferPool;
 	friend class PipelineHandler;
 	friend class Request;
+	friend class Stream;
 	friend class V4L2VideoDevice;
 
 	void cancel();
diff --git a/include/libcamera/stream.h b/include/libcamera/stream.h
index 796f1aff2602..4c034c113ddb 100644
--- a/include/libcamera/stream.h
+++ b/include/libcamera/stream.h
@@ -85,6 +85,8 @@ public:
 	const StreamConfiguration &configuration() const { return configuration_; }
 	MemoryType memoryType() const { return memoryType_; }
 
+	Buffer *mapBuffer(Buffer *requestBuffer);
+
 protected:
 	friend class Camera;
 
@@ -94,6 +96,9 @@ protected:
 	BufferPool bufferPool_;
 	StreamConfiguration configuration_;
 	MemoryType memoryType_;
+
+	std::vector<Buffer *> mappableBuffers_;
+	std::map<unsigned int, Buffer *> bufferMaps_;
 };
 
 } /* namespace libcamera */
diff --git a/src/libcamera/stream.cpp b/src/libcamera/stream.cpp
index b6292427d3a2..f36336857ad6 100644
--- a/src/libcamera/stream.cpp
+++ b/src/libcamera/stream.cpp
@@ -13,6 +13,8 @@
 #include <iomanip>
 #include <sstream>
 
+#include <libcamera/request.h>
+
 #include "log.h"
 
 /**
@@ -445,6 +447,26 @@ void Stream::createBuffers(unsigned int count)
 		return;
 
 	bufferPool_.createBuffers(count);
+
+	/* Streams with internal memory usage do not need buffer mapping. */
+	if (memoryType_ == InternalMemory)
+		return;
+
+	/*
+	 * Prepare for buffer mapping by queuing all buffers from the internal
+	 * pool. Each external buffer presented by application will be mapped
+	 * on an internal one.
+	 */
+	mappableBuffers_.clear();
+	for (Buffer &buffer : bufferPool_.buffers()) {
+		/* Reserve all planes to support mapping multiplanar buffers. */
+		buffer.planes().clear();
+		/* \todo: I would use VIDEO_MAX_PLANES but that's V4L2 stuff.. */
+		for (unsigned int i = 0; i < 3; ++i)
+			buffer.planes().emplace_back();
+
+		mappableBuffers_.push_back(&buffer);
+	}
 }
 
 /**
@@ -457,6 +479,50 @@ void Stream::destroyBuffers()
 	createBuffers(0);
 }
 
+/**
+ * \brief Map the buffer an application has associated with a Request to an
+ * internl one
+ *
+ * \todo Rewrite documentation
+ * If the Stream uses external memory, we need to map the externally
+ * provided buffer to an internal one, trying to keep a best effort
+ * association based on the Buffer's last usage time.
+ * External and internal buffers are associated by using the dmabuf
+ * fds as key.
+ */
+Buffer *Stream::mapBuffer(Buffer *requestBuffer)
+{
+	/*
+	 * \todo Multiplane APIs have one fd per plane, the key should be
+	 * hashed using all the planes fds.
+	 */
+	unsigned int key = requestBuffer->planes()[0].dmabuf();
+
+	/* If the buffer has already been mapped, just return it. */
+	auto mapped = bufferMaps_.find(key);
+	if (mapped != bufferMaps_.end())
+		return mapped->second;
+
+	/*
+	 * Remove the last recently used buffer from the circular list and
+	 * use it for mapping.
+	 */
+	auto mappable = mappableBuffers_.begin();
+	Buffer *buffer = *mappable;
+	mappableBuffers_.erase(mappable);
+	mappableBuffers_.push_back(buffer);
+
+	/* \todo: Support multiplanar external buffers. */
+	buffer->planes()[0].setDmabuf(key, 0);
+
+	/* Pipeline handlers use request_ at buffer completion time. */
+	buffer->request_ = requestBuffer->request();
+
+	bufferMaps_[key] = buffer;
+
+	return buffer;
+}
+
 /**
  * \var Stream::bufferPool_
  * \brief The pool of buffers associated with the stream
