Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(145)

Side by Side Diff: remoting/host/video_frame_capturer_win.cc

Issue 10790075: Rename Capturer to VideoFrameCapturer. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/src
Patch Set: rebased. Created 8 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « remoting/host/video_frame_capturer_unittest.cc ('k') | remoting/remoting.gyp » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 #include "remoting/host/video_frame_capturer.h"
6
7 #include <windows.h>
8
9 #include "base/file_path.h"
10 #include "base/logging.h"
11 #include "base/memory/scoped_ptr.h"
12 #include "base/scoped_native_library.h"
13 #include "base/utf_string_conversions.h"
14 #include "base/win/scoped_gdi_object.h"
15 #include "base/win/scoped_hdc.h"
16 #include "remoting/base/capture_data.h"
17 #include "remoting/host/desktop_win.h"
18 #include "remoting/host/differ.h"
19 #include "remoting/host/scoped_thread_desktop_win.h"
20 #include "remoting/host/video_frame_capturer_helper.h"
21 #include "remoting/proto/control.pb.h"
22
23 namespace remoting {
24
25 namespace {
26
27 // Constants from dwmapi.h.
28 const UINT DWM_EC_DISABLECOMPOSITION = 0;
29 const UINT DWM_EC_ENABLECOMPOSITION = 1;
30
31 typedef HRESULT (WINAPI * DwmEnableCompositionFunc)(UINT);
32
33 const char kDwmapiLibraryName[] = "dwmapi";
34
35 // Pixel colors used when generating cursor outlines.
36 const uint32 kPixelBgraBlack = 0xff000000;
37 const uint32 kPixelBgraWhite = 0xffffffff;
38 const uint32 kPixelBgraTransparent = 0x00000000;
39
40 // VideoFrameCapturerWin captures 32bit RGB using GDI.
41 //
42 // VideoFrameCapturerWin is double-buffered as required by VideoFrameCapturer.
43 // See remoting/host/video_frame_capturer.h.
44 class VideoFrameCapturerWin : public VideoFrameCapturer {
45 public:
46 VideoFrameCapturerWin();
47 virtual ~VideoFrameCapturerWin();
48
49 // Overridden from VideoFrameCapturer:
50 virtual void Start(const CursorShapeChangedCallback& callback) OVERRIDE;
51 virtual void Stop() OVERRIDE;
52 virtual void ScreenConfigurationChanged() OVERRIDE;
53 virtual media::VideoFrame::Format pixel_format() const OVERRIDE;
54 virtual void ClearInvalidRegion() OVERRIDE;
55 virtual void InvalidateRegion(const SkRegion& invalid_region) OVERRIDE;
56 virtual void InvalidateScreen(const SkISize& size) OVERRIDE;
57 virtual void InvalidateFullScreen() OVERRIDE;
58 virtual void CaptureInvalidRegion(
59 const CaptureCompletedCallback& callback) OVERRIDE;
60 virtual const SkISize& size_most_recent() const OVERRIDE;
61
62 private:
63 struct VideoFrameBuffer {
64 VideoFrameBuffer(void* data, const SkISize& size, int bytes_per_pixel,
65 int bytes_per_row)
66 : data(data), size(size), bytes_per_pixel(bytes_per_pixel),
67 bytes_per_row(bytes_per_row) {
68 }
69 VideoFrameBuffer() {
70 data = 0;
71 size = SkISize::Make(0, 0);
72 bytes_per_pixel = 0;
73 bytes_per_row = 0;
74 }
75 void* data;
76 SkISize size;
77 int bytes_per_pixel;
78 int bytes_per_row;
79 int resource_generation;
80 };
81
82 // Make sure that the device contexts and the current bufffer match the screen
83 // configuration.
84 void PrepareCaptureResources();
85
86 // Allocates the specified capture buffer using the current device contexts
87 // and desktop dimensions, releasing any pre-existing buffer.
88 void AllocateBuffer(int buffer_index);
89
90 void CalculateInvalidRegion();
91 void CaptureRegion(const SkRegion& region,
92 const CaptureCompletedCallback& callback);
93
94 // Generates an image in the current buffer.
95 void CaptureImage();
96
97 // Expand the cursor shape to add a white outline for visibility against
98 // dark backgrounds.
99 void AddCursorOutline(int width, int height, uint32* dst);
100
101 // Capture the current cursor shape.
102 void CaptureCursor();
103
104 // A thread-safe list of invalid rectangles, and the size of the most
105 // recently captured screen.
106 VideoFrameCapturerHelper helper_;
107
108 // Callback notified whenever the cursor shape is changed.
109 CursorShapeChangedCallback cursor_shape_changed_callback_;
110
111 // Snapshot of the last cursor bitmap we sent to the client. This is used
112 // to diff against the current cursor so we only send a cursor-change
113 // message when the shape has changed.
114 scoped_array<uint8> last_cursor_;
115 SkISize last_cursor_size_;
116
117 // There are two buffers for the screen images, as required by Capturer.
118 static const int kNumBuffers = 2;
119 VideoFrameBuffer buffers_[kNumBuffers];
120
121 ScopedThreadDesktopWin desktop_;
122
123 // GDI resources used for screen capture.
124 scoped_ptr<base::win::ScopedGetDC> desktop_dc_;
125 base::win::ScopedCreateDC memory_dc_;
126 base::win::ScopedBitmap target_bitmap_[kNumBuffers];
127 int resource_generation_;
128
129 // Rectangle describing the bounds of the desktop device context.
130 SkIRect desktop_dc_rect_;
131
132 // The current buffer with valid data for reading.
133 int current_buffer_;
134
135 // Format of pixels returned in buffer.
136 media::VideoFrame::Format pixel_format_;
137
138 // Class to calculate the difference between two screen bitmaps.
139 scoped_ptr<Differ> differ_;
140
141 base::ScopedNativeLibrary dwmapi_library_;
142 DwmEnableCompositionFunc composition_func_;
143
144 DISALLOW_COPY_AND_ASSIGN(VideoFrameCapturerWin);
145 };
146
147 // 3780 pixels per meter is equivalent to 96 DPI, typical on desktop monitors.
148 static const int kPixelsPerMeter = 3780;
149 // 32 bit RGBA is 4 bytes per pixel.
150 static const int kBytesPerPixel = 4;
151
152 VideoFrameCapturerWin::VideoFrameCapturerWin()
153 : last_cursor_size_(SkISize::Make(0, 0)),
154 desktop_dc_rect_(SkIRect::MakeEmpty()),
155 resource_generation_(0),
156 current_buffer_(0),
157 pixel_format_(media::VideoFrame::RGB32),
158 composition_func_(NULL) {
159 ScreenConfigurationChanged();
160 }
161
162 VideoFrameCapturerWin::~VideoFrameCapturerWin() {
163 }
164
165 media::VideoFrame::Format VideoFrameCapturerWin::pixel_format() const {
166 return pixel_format_;
167 }
168
169 void VideoFrameCapturerWin::ClearInvalidRegion() {
170 helper_.ClearInvalidRegion();
171 }
172
173 void VideoFrameCapturerWin::InvalidateRegion(const SkRegion& invalid_region) {
174 helper_.InvalidateRegion(invalid_region);
175 }
176
177 void VideoFrameCapturerWin::InvalidateScreen(const SkISize& size) {
178 helper_.InvalidateScreen(size);
179 }
180
181 void VideoFrameCapturerWin::InvalidateFullScreen() {
182 helper_.InvalidateFullScreen();
183 }
184
185 void VideoFrameCapturerWin::CaptureInvalidRegion(
186 const CaptureCompletedCallback& callback) {
187 // Force the system to power-up display hardware, if it has been suspended.
188 SetThreadExecutionState(ES_DISPLAY_REQUIRED);
189
190 // Perform the capture.
191 CalculateInvalidRegion();
192 SkRegion invalid_region;
193 helper_.SwapInvalidRegion(&invalid_region);
194 CaptureRegion(invalid_region, callback);
195
196 // Check for cursor shape update.
197 CaptureCursor();
198 }
199
200 const SkISize& VideoFrameCapturerWin::size_most_recent() const {
201 return helper_.size_most_recent();
202 }
203
204 void VideoFrameCapturerWin::Start(
205 const CursorShapeChangedCallback& callback) {
206 cursor_shape_changed_callback_ = callback;
207
208 // Load dwmapi.dll dynamically since it is not available on XP.
209 if (!dwmapi_library_.is_valid()) {
210 FilePath path(base::GetNativeLibraryName(UTF8ToUTF16(kDwmapiLibraryName)));
211 dwmapi_library_.Reset(base::LoadNativeLibrary(path, NULL));
212 }
213
214 if (dwmapi_library_.is_valid() && composition_func_ == NULL) {
215 composition_func_ = static_cast<DwmEnableCompositionFunc>(
216 dwmapi_library_.GetFunctionPointer("DwmEnableComposition"));
217 }
218
219 // Vote to disable Aero composited desktop effects while capturing. Windows
220 // will restore Aero automatically if the process exits. This has no effect
221 // under Windows 8 or higher. See crbug.com/124018.
222 if (composition_func_ != NULL) {
223 (*composition_func_)(DWM_EC_DISABLECOMPOSITION);
224 }
225 }
226
227 void VideoFrameCapturerWin::Stop() {
228 // Restore Aero.
229 if (composition_func_ != NULL) {
230 (*composition_func_)(DWM_EC_ENABLECOMPOSITION);
231 }
232 }
233
234 void VideoFrameCapturerWin::ScreenConfigurationChanged() {
235 // We poll for screen configuration changes, so ignore notifications.
236 }
237
238 void VideoFrameCapturerWin::PrepareCaptureResources() {
239 // Switch to the desktop receiving user input if different from the current
240 // one.
241 scoped_ptr<DesktopWin> input_desktop = DesktopWin::GetInputDesktop();
242 if (input_desktop.get() != NULL && !desktop_.IsSame(*input_desktop)) {
243 // Release GDI resources otherwise SetThreadDesktop will fail.
244 desktop_dc_.reset();
245 memory_dc_.Set(NULL);
246
247 // If SetThreadDesktop() fails, the thread is still assigned a desktop.
248 // So we can continue capture screen bits, just from the wrong desktop.
249 desktop_.SetThreadDesktop(input_desktop.Pass());
250 }
251
252 // If the display bounds have changed then recreate GDI resources.
253 // TODO(wez): Also check for pixel format changes.
254 SkIRect screen_rect(SkIRect::MakeXYWH(
255 GetSystemMetrics(SM_XVIRTUALSCREEN),
256 GetSystemMetrics(SM_YVIRTUALSCREEN),
257 GetSystemMetrics(SM_CXVIRTUALSCREEN),
258 GetSystemMetrics(SM_CYVIRTUALSCREEN)));
259 if (screen_rect != desktop_dc_rect_) {
260 desktop_dc_.reset();
261 memory_dc_.Set(NULL);
262 desktop_dc_rect_.setEmpty();
263 }
264
265 // Create GDI device contexts to capture from the desktop into memory, and
266 // allocate buffers to capture into.
267 if (desktop_dc_.get() == NULL) {
268 DCHECK(memory_dc_.Get() == NULL);
269
270 desktop_dc_.reset(new base::win::ScopedGetDC(NULL));
271 memory_dc_.Set(CreateCompatibleDC(*desktop_dc_));
272 desktop_dc_rect_ = screen_rect;
273
274 ++resource_generation_;
275 }
276
277 // If the current buffer is from an older generation then allocate a new one.
278 // Note that we can't reallocate other buffers at this point, since the caller
279 // may still be reading from them.
280 if (resource_generation_ != buffers_[current_buffer_].resource_generation) {
281 AllocateBuffer(current_buffer_);
282 InvalidateFullScreen();
283 }
284 }
285
286 void VideoFrameCapturerWin::AllocateBuffer(int buffer_index) {
287 DCHECK(desktop_dc_.get() != NULL);
288 DCHECK(memory_dc_.Get() != NULL);
289 // Windows requires DIB sections' rows to start DWORD-aligned, which is
290 // implicit when working with RGB32 pixels.
291 DCHECK_EQ(pixel_format_, media::VideoFrame::RGB32);
292
293 // Describe a device independent bitmap (DIB) that is the size of the desktop.
294 BITMAPINFO bmi;
295 memset(&bmi, 0, sizeof(bmi));
296 bmi.bmiHeader.biHeight = -desktop_dc_rect_.height();
297 bmi.bmiHeader.biWidth = desktop_dc_rect_.width();
298 bmi.bmiHeader.biPlanes = 1;
299 bmi.bmiHeader.biBitCount = kBytesPerPixel * 8;
300 bmi.bmiHeader.biSize = sizeof(bmi.bmiHeader);
301 int bytes_per_row = desktop_dc_rect_.width() * kBytesPerPixel;
302 bmi.bmiHeader.biSizeImage = bytes_per_row * desktop_dc_rect_.height();
303 bmi.bmiHeader.biXPelsPerMeter = kPixelsPerMeter;
304 bmi.bmiHeader.biYPelsPerMeter = kPixelsPerMeter;
305
306 // Create the DIB, and store a pointer to its pixel buffer.
307 target_bitmap_[buffer_index] =
308 CreateDIBSection(*desktop_dc_, &bmi, DIB_RGB_COLORS,
309 static_cast<void**>(&buffers_[buffer_index].data),
310 NULL, 0);
311 buffers_[buffer_index].size = SkISize::Make(bmi.bmiHeader.biWidth,
312 std::abs(bmi.bmiHeader.biHeight));
313 buffers_[buffer_index].bytes_per_pixel = bmi.bmiHeader.biBitCount / 8;
314 buffers_[buffer_index].bytes_per_row =
315 bmi.bmiHeader.biSizeImage / std::abs(bmi.bmiHeader.biHeight);
316 }
317
318 void VideoFrameCapturerWin::CalculateInvalidRegion() {
319 CaptureImage();
320
321 const VideoFrameBuffer& current = buffers_[current_buffer_];
322
323 // Find the previous and current screens.
324 int prev_buffer_id = current_buffer_ - 1;
325 if (prev_buffer_id < 0) {
326 prev_buffer_id = kNumBuffers - 1;
327 }
328 const VideoFrameBuffer& prev = buffers_[prev_buffer_id];
329
330 // Maybe the previous and current screens can't be differenced.
331 if ((current.size != prev.size) ||
332 (current.bytes_per_pixel != prev.bytes_per_pixel) ||
333 (current.bytes_per_row != prev.bytes_per_row)) {
334 InvalidateScreen(current.size);
335 return;
336 }
337
338 // Make sure the differencer is set up correctly for these previous and
339 // current screens.
340 if (!differ_.get() ||
341 (differ_->width() != current.size.width()) ||
342 (differ_->height() != current.size.height()) ||
343 (differ_->bytes_per_pixel() != current.bytes_per_pixel) ||
344 (differ_->bytes_per_row() != current.bytes_per_row)) {
345 differ_.reset(new Differ(current.size.width(), current.size.height(),
346 current.bytes_per_pixel, current.bytes_per_row));
347 }
348
349 SkRegion region;
350 differ_->CalcDirtyRegion(prev.data, current.data, &region);
351
352 InvalidateRegion(region);
353 }
354
355 void VideoFrameCapturerWin::CaptureRegion(
356 const SkRegion& region,
357 const CaptureCompletedCallback& callback) {
358 const VideoFrameBuffer& buffer = buffers_[current_buffer_];
359 current_buffer_ = (current_buffer_ + 1) % kNumBuffers;
360
361 DataPlanes planes;
362 planes.data[0] = static_cast<uint8*>(buffer.data);
363 planes.strides[0] = buffer.bytes_per_row;
364
365 scoped_refptr<CaptureData> data(new CaptureData(planes,
366 buffer.size,
367 pixel_format_));
368 data->mutable_dirty_region() = region;
369
370 helper_.set_size_most_recent(data->size());
371
372 callback.Run(data);
373 }
374
375 void VideoFrameCapturerWin::CaptureImage() {
376 // Make sure the GDI capture resources are up-to-date.
377 PrepareCaptureResources();
378
379 // Select the target bitmap into the memory dc.
380 SelectObject(memory_dc_, target_bitmap_[current_buffer_]);
381
382 // And then copy the rect from desktop to memory.
383 BitBlt(memory_dc_, 0, 0, buffers_[current_buffer_].size.width(),
384 buffers_[current_buffer_].size.height(), *desktop_dc_,
385 desktop_dc_rect_.x(), desktop_dc_rect_.y(),
386 SRCCOPY | CAPTUREBLT);
387 }
388
389 void VideoFrameCapturerWin::AddCursorOutline(int width,
390 int height,
391 uint32* dst) {
392 for (int y = 0; y < height; y++) {
393 for (int x = 0; x < width; x++) {
394 // If this is a transparent pixel (bgr == 0 and alpha = 0), check the
395 // neighbor pixels to see if this should be changed to an outline pixel.
396 if (*dst == kPixelBgraTransparent) {
397 // Change to white pixel if any neighbors (top, bottom, left, right)
398 // are black.
399 if ((y > 0 && dst[-width] == kPixelBgraBlack) ||
400 (y < height - 1 && dst[width] == kPixelBgraBlack) ||
401 (x > 0 && dst[-1] == kPixelBgraBlack) ||
402 (x < width - 1 && dst[1] == kPixelBgraBlack)) {
403 *dst = kPixelBgraWhite;
404 }
405 }
406 dst++;
407 }
408 }
409 }
410
411 void VideoFrameCapturerWin::CaptureCursor() {
412 CURSORINFO cursor_info;
413 cursor_info.cbSize = sizeof(CURSORINFO);
414 if (!GetCursorInfo(&cursor_info)) {
415 VLOG(3) << "Unable to get cursor info. Error = " << GetLastError();
416 return;
417 }
418
419 // Note that this does not need to be freed.
420 HCURSOR hcursor = cursor_info.hCursor;
421 ICONINFO iinfo;
422 if (!GetIconInfo(hcursor, &iinfo)) {
423 VLOG(3) << "Unable to get cursor icon info. Error = " << GetLastError();
424 return;
425 }
426 int hotspot_x = iinfo.xHotspot;
427 int hotspot_y = iinfo.yHotspot;
428
429 // Get the cursor bitmap.
430 base::win::ScopedBitmap hbitmap;
431 BITMAP bitmap;
432 bool color_bitmap;
433 if (iinfo.hbmColor) {
434 // Color cursor bitmap.
435 color_bitmap = true;
436 hbitmap.Set((HBITMAP)CopyImage(iinfo.hbmColor, IMAGE_BITMAP, 0, 0,
437 LR_CREATEDIBSECTION));
438 if (!hbitmap.Get()) {
439 VLOG(3) << "Unable to copy color cursor image. Error = "
440 << GetLastError();
441 return;
442 }
443
444 // Free the color and mask bitmaps since we only need our copy.
445 DeleteObject(iinfo.hbmColor);
446 DeleteObject(iinfo.hbmMask);
447 } else {
448 // Black and white (xor) cursor.
449 color_bitmap = false;
450 hbitmap.Set(iinfo.hbmMask);
451 }
452
453 if (!GetObject(hbitmap.Get(), sizeof(BITMAP), &bitmap)) {
454 VLOG(3) << "Unable to get cursor bitmap. Error = " << GetLastError();
455 return;
456 }
457
458 int width = bitmap.bmWidth;
459 int height = bitmap.bmHeight;
460 // For non-color cursors, the mask contains both an AND and an XOR mask and
461 // the height includes both. Thus, the width is correct, but we need to
462 // divide by 2 to get the correct mask height.
463 if (!color_bitmap) {
464 height /= 2;
465 }
466 int data_size = height * width * kBytesPerPixel;
467
468 scoped_ptr<protocol::CursorShapeInfo> cursor_proto(
469 new protocol::CursorShapeInfo());
470 cursor_proto->mutable_data()->resize(data_size);
471 uint8* cursor_dst_data = const_cast<uint8*>(reinterpret_cast<const uint8*>(
472 cursor_proto->mutable_data()->data()));
473
474 // Copy/convert cursor bitmap into format needed by chromotocol.
475 int row_bytes = bitmap.bmWidthBytes;
476 if (color_bitmap) {
477 if (bitmap.bmPlanes != 1 || bitmap.bmBitsPixel != 32) {
478 VLOG(3) << "Unsupported color cursor format. Error = " << GetLastError();
479 return;
480 }
481
482 // Cursor bitmap is stored upside-down on Windows. Flip the rows and store
483 // it in the proto.
484 uint8* cursor_src_data = reinterpret_cast<uint8*>(bitmap.bmBits);
485 uint8* src = cursor_src_data + ((height - 1) * row_bytes);
486 uint8* dst = cursor_dst_data;
487 for (int row = 0; row < height; row++) {
488 memcpy(dst, src, row_bytes);
489 dst += width * kBytesPerPixel;
490 src -= row_bytes;
491 }
492 } else {
493 if (bitmap.bmPlanes != 1 || bitmap.bmBitsPixel != 1) {
494 VLOG(3) << "Unsupported cursor mask format. Error = " << GetLastError();
495 return;
496 }
497
498 // x2 because there are 2 masks in the bitmap: AND and XOR.
499 int mask_bytes = height * row_bytes * 2;
500 scoped_array<uint8> mask(new uint8[mask_bytes]);
501 if (!GetBitmapBits(hbitmap.Get(), mask_bytes, mask.get())) {
502 VLOG(3) << "Unable to get cursor mask bits. Error = " << GetLastError();
503 return;
504 }
505 uint8* and_mask = mask.get();
506 uint8* xor_mask = mask.get() + height * row_bytes;
507 uint8* dst = cursor_dst_data;
508 bool add_outline = false;
509 for (int y = 0; y < height; y++) {
510 for (int x = 0; x < width; x++) {
511 int byte = y * row_bytes + x / 8;
512 int bit = 7 - x % 8;
513 int and = and_mask[byte] & (1 << bit);
514 int xor = xor_mask[byte] & (1 << bit);
515
516 // The two cursor masks combine as follows:
517 // AND XOR Windows Result Our result RGB Alpha
518 // 0 0 Black Black 00 ff
519 // 0 1 White White ff ff
520 // 1 0 Screen Transparent 00 00
521 // 1 1 Reverse-screen Black 00 ff
522 // Since we don't support XOR cursors, we replace the "Reverse Screen"
523 // with black. In this case, we also add an outline around the cursor
524 // so that it is visible against a dark background.
525 int rgb = (!and && xor) ? 0xff : 0x00;
526 int alpha = (and && !xor) ? 0x00 : 0xff;
527 *dst++ = rgb;
528 *dst++ = rgb;
529 *dst++ = rgb;
530 *dst++ = alpha;
531 if (and && xor) {
532 add_outline = true;
533 }
534 }
535 }
536 if (add_outline) {
537 AddCursorOutline(width, height,
538 reinterpret_cast<uint32*>(cursor_dst_data));
539 }
540 }
541
542 // Compare the current cursor with the last one we sent to the client. If
543 // they're the same, then don't bother sending the cursor again.
544 if (last_cursor_size_.equals(width, height) &&
545 memcmp(last_cursor_.get(), cursor_dst_data, data_size) == 0) {
546 return;
547 }
548
549 VLOG(3) << "Sending updated cursor: " << width << "x" << height;
550
551 cursor_proto->set_width(width);
552 cursor_proto->set_height(height);
553 cursor_proto->set_hotspot_x(hotspot_x);
554 cursor_proto->set_hotspot_y(hotspot_y);
555
556 // Record the last cursor image that we sent to the client.
557 last_cursor_.reset(new uint8[data_size]);
558 memcpy(last_cursor_.get(), cursor_dst_data, data_size);
559 last_cursor_size_ = SkISize::Make(width, height);
560
561 cursor_shape_changed_callback_.Run(cursor_proto.Pass());
562 }
563
564 } // namespace
565
566 // static
567 VideoFrameCapturer* VideoFrameCapturer::Create() {
568 return new VideoFrameCapturerWin();
569 }
570
571 } // namespace remoting
OLDNEW
« no previous file with comments | « remoting/host/video_frame_capturer_unittest.cc ('k') | remoting/remoting.gyp » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698