From afceea3fe6ecf4096a33314d40240e743171f6ef Mon Sep 17 00:00:00 2001 From: "Owen W. Taylor" Date: Fri, 13 Mar 2009 17:14:31 -0400 Subject: [PATCH] Add a built-in screencast recording facility For development and demonstration purposes, it's neat to be able to record a screencast of gnome-shell without any external setup. Built-in recording can also give much better quality than is possible with a generic desktop recording, since we hook right into the paint loop. src/shell-recorder.[ch]: A general-purposes object to record a Clutter stage to a GStreamer stream. src/shell-recorder-src.[ch]: A simple GStreamer source element (similar to appsrc in the most recent versions of GStreamer) for injecting captured data into a GStreamer pipeline. src/test-recorder.c: Test program that records a simple animation. configure.ac src/Makefile.am: Add machinery to conditionally build ShellRecorder. tools/build/gnome-shell-build-setup.sh: Add gstreamer packages to the list of required packages for Fedora. js/ui/main.js: Hook up the recorder to a MetaScreen ::toggle-recording keybinding. http://bugzilla.gnome.org/show_bug.cgi?id=575290 --- .gitignore | 2 + configure.ac | 22 +- js/ui/main.js | 19 + src/Makefile.am | 26 + src/shell-recorder-src.c | 298 +++++ src/shell-recorder-src.h | 39 + src/shell-recorder.c | 1666 ++++++++++++++++++++++++ src/shell-recorder.h | 43 + src/test-recorder.c | 95 ++ tools/build/gnome-shell-build-setup.sh | 1 + 10 files changed, 2210 insertions(+), 1 deletion(-) create mode 100644 src/shell-recorder-src.c create mode 100644 src/shell-recorder-src.h create mode 100644 src/shell-recorder.c create mode 100644 src/shell-recorder.h create mode 100644 src/test-recorder.c diff --git a/.gitignore b/.gitignore index 4d4c17e92..a57399ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ src/Makefile src/Makefile.in src/gnomeshell-taskpanel src/gnome-shell +src/test-recorder +src/test-recorder.ogg stamp-h1 diff --git a/configure.ac b/configure.ac index 756fb4fa6..410e9930c 100644 --- a/configure.ac +++ b/configure.ac @@ -18,7 +18,27 @@ AC_SUBST(GETTEXT_PACKAGE) AC_DEFINE_UNQUOTED(GETTEXT_PACKAGE, "$GETTEXT_PACKAGE", [The prefix for our gettext translation domains.]) -PKG_CHECK_MODULES(MUTTER_PLUGIN, gtk+-2.0 dbus-glib-1 metacity-plugins gjs-gi-1.0) +PKG_PROG_PKG_CONFIG(0.16) + +# We need at least this, since gst_plugin_register_static() was added +# in 0.10.16, but nothing older than 0.10.21 has been tested. +GSTREAMER_MIN_VERSION=0.10.16 + +recorder_modules= +build_recorder=false +AC_MSG_CHECKING([for GStreamer (needed for recording functionality)]) +if $PKG_CONFIG --exists gstreamer-0.10 '>=' $GSTREAMER_MIN_VERSION ; then + AC_MSG_RESULT(yes) + build_recorder=true + recorder_modules="gstreamer-0.10 gstreamer-base-0.10 xfixes" + PKG_CHECK_MODULES(TEST_SHELL_RECORDER, $recorder_modules clutter-0.9) +else + AC_MSG_RESULT(no) +fi + +AM_CONDITIONAL(BUILD_RECORDER, $build_recorder) + +PKG_CHECK_MODULES(MUTTER_PLUGIN, gtk+-2.0 dbus-glib-1 metacity-plugins gjs-gi-1.0 $recorder_modules) PKG_CHECK_MODULES(TIDY, clutter-0.9) PKG_CHECK_MODULES(BIG, clutter-0.9 gtk+-2.0 librsvg-2.0) PKG_CHECK_MODULES(GDMUSER, dbus-glib-1 gtk+-2.0) diff --git a/js/ui/main.js b/js/ui/main.js index 0b025197e..449553c04 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -20,6 +20,7 @@ let overlay = null; let overlayActive = false; let runDialog = null; let wm = null; +let recorder = null; function start() { let global = Shell.Global.get(); @@ -71,6 +72,24 @@ function start() { show_overlay(); } }; + + global.screen.connect('toggle-recording', function() { + if (recorder == null) { + // We have to initialize GStreamer first. This isn't done + // inside ShellRecorder to make it usable inside projects + // with other usage of GStreamer. + let Gst = imports.gi.Gst; + Gst.init(null, null); + recorder = new Shell.Recorder({ stage: global.stage }); + } + + if (recorder.is_recording()) { + recorder.pause(); + } else { + recorder.record(); + } + }); + display.connect('overlay-key', toggleOverlay); global.connect('panel-main-menu', toggleOverlay); diff --git a/src/Makefile.am b/src/Makefile.am index cc29a833f..9b2da575f 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -66,6 +66,32 @@ libgnome_shell_la_SOURCES = \ # ClutterGLXTexturePixmap is currently not wrapped non_gir_sources = shell-gtkwindow-actor.h + +shell_recorder_sources = \ + shell-recorder.c \ + shell-recorder.h \ + shell-recorder-src.c \ + shell-recorder-src.h + +# Custom element is an internal detail +shell_recorder_non_gir_sources = \ + shell-recorder-src.c \ + shell-recorder-src.h + +if BUILD_RECORDER +libgnome_shell_la_SOURCES += $(shell_recorder_sources) +non_gir_sources += $(shell_recorder_non_gir_sources) + +noinst_PROGRAMS = test-recorder + +test_recorder_CPPFLAGS = $(TEST_SHELL_RECORDER_CFLAGS) +test_recorder_LDADD = $(TEST_SHELL_RECORDER_LIBS) + +test_recorder_SOURCES = \ + $(shell_recorder_sources) \ + test-recorder.c +endif BUILD_RECORDER + libgnome_shell_la_gir_sources = \ $(filter-out $(non_gir_sources), $(libgnome_shell_la_SOURCES)) diff --git a/src/shell-recorder-src.c b/src/shell-recorder-src.c new file mode 100644 index 000000000..9bce4d746 --- /dev/null +++ b/src/shell-recorder-src.c @@ -0,0 +1,298 @@ +#include + +#include "shell-recorder-src.h" + +struct _ShellRecorderSrc +{ + GstPushSrc parent; + + GMutex *mutex; + + GstCaps *caps; + GAsyncQueue *queue; + gboolean closed; + guint memory_used; + guint memory_used_update_idle; +}; + +struct _ShellRecorderSrcClass +{ + GstPushSrcClass parent_class; +}; + +enum { + PROP_0, + PROP_CAPS, + PROP_MEMORY_USED +}; + +/* Special marker value once the source is closed */ +#define RECORDER_QUEUE_END ((GstBuffer *)1) + +GST_BOILERPLATE(ShellRecorderSrc, shell_recorder_src, GstPushSrc, GST_TYPE_PUSH_SRC); + +static void +shell_recorder_src_init (ShellRecorderSrc *src, + ShellRecorderSrcClass *klass) +{ + src->queue = g_async_queue_new (); + src->mutex = g_mutex_new (); +} + +static void +shell_recorder_src_base_init (gpointer klass) +{ +} + +static gboolean +shell_recorder_src_memory_used_update_idle (gpointer data) +{ + ShellRecorderSrc *src = data; + + g_mutex_lock (src->mutex); + src->memory_used_update_idle = 0; + g_mutex_unlock (src->mutex); + + g_object_notify (G_OBJECT (src), "memory-used"); + + return FALSE; +} + +/* The memory_used property is used to monitor buffer usage, + * so we marshal notification back to the main loop thread. + */ +static void +shell_recorder_src_update_memory_used (ShellRecorderSrc *src, + int delta) +{ + g_mutex_lock (src->mutex); + src->memory_used += delta; + if (src->memory_used_update_idle == 0) + src->memory_used_update_idle = g_idle_add (shell_recorder_src_memory_used_update_idle, src); + g_mutex_unlock (src->mutex); +} + +/* The create() virtual function is responsible for returning the next buffer. + * We just pop buffers off of the queue and block if necessary. + */ +static GstFlowReturn +shell_recorder_src_create (GstPushSrc *push_src, + GstBuffer **buffer_out) +{ + ShellRecorderSrc *src = SHELL_RECORDER_SRC (push_src); + GstBuffer *buffer; + + if (src->closed) + return GST_FLOW_UNEXPECTED; + + buffer = g_async_queue_pop (src->queue); + if (buffer == RECORDER_QUEUE_END) + { + /* Returning UNEXPECTED here will cause a EOS message to be sent */ + src->closed = TRUE; + return GST_FLOW_UNEXPECTED; + } + + shell_recorder_src_update_memory_used (src, + - (int)(GST_BUFFER_SIZE(buffer) / 1024)); + + *buffer_out = buffer; + + return GST_FLOW_OK; +} + +static void +shell_recorder_src_set_caps (ShellRecorderSrc *src, + const GstCaps *caps) +{ + if (caps == src->caps) + return; + + if (src->caps != NULL) + { + gst_caps_unref (src->caps); + src->caps = NULL; + } + + if (caps) + { + /* The capabilities will be negotated with the downstream element + * and set on the pad when the first buffer is pushed. + */ + src->caps = gst_caps_copy (caps); + } + else + src->caps = NULL; +} + +static void +shell_recorder_src_finalize (GObject *object) +{ + ShellRecorderSrc *src = SHELL_RECORDER_SRC (object); + + if (src->memory_used_update_idle) + g_source_remove (src->memory_used_update_idle); + + shell_recorder_src_set_caps (src, NULL); + g_async_queue_unref (src->queue); + + g_mutex_free (src->mutex); + + G_OBJECT_CLASS (parent_class)->finalize (object); +} + +static void +shell_recorder_src_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + ShellRecorderSrc *src = SHELL_RECORDER_SRC (object); + + switch (prop_id) + { + case PROP_CAPS: + shell_recorder_src_set_caps (src, gst_value_get_caps (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +shell_recorder_src_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + ShellRecorderSrc *src = SHELL_RECORDER_SRC (object); + + switch (prop_id) + { + case PROP_CAPS: + gst_value_set_caps (value, src->caps); + break; + case PROP_MEMORY_USED: + g_mutex_lock (src->mutex); + g_value_set_uint (value, src->memory_used); + g_mutex_unlock (src->mutex); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +shell_recorder_src_class_init (ShellRecorderSrcClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GstElementClass *element_class = GST_ELEMENT_CLASS (klass); + GstPushSrcClass *push_src_class = GST_PUSH_SRC_CLASS (klass); + + static GstStaticPadTemplate src_template = + GST_STATIC_PAD_TEMPLATE ("src", + GST_PAD_SRC, + GST_PAD_ALWAYS, + GST_STATIC_CAPS_ANY); + + object_class->finalize = shell_recorder_src_finalize; + object_class->set_property = shell_recorder_src_set_property; + object_class->get_property = shell_recorder_src_get_property; + + push_src_class->create = shell_recorder_src_create; + + g_object_class_install_property (object_class, + PROP_CAPS, + g_param_spec_boxed ("caps", + "Caps", + "Fixed GstCaps for the source", + GST_TYPE_CAPS, + G_PARAM_READWRITE)); + g_object_class_install_property (object_class, + PROP_MEMORY_USED, + g_param_spec_uint ("memory-used", + "Memory Used", + "Memory currently used by the queue (in kB)", + 0, G_MAXUINT, 0, + G_PARAM_READABLE)); + gst_element_class_add_pad_template (element_class, + gst_static_pad_template_get (&src_template)); + + gst_element_class_set_details_simple (element_class, + "ShellRecorderSrc", + "Generic/Src", + "Feed screen capture data to a pipeline", + "Owen Taylor "); +} + +/** + * shell_recorder_src_add_buffer: + * + * Adds a buffer to the internal queue to be pushed out at the next opportunity. + * There is no flow control, so arbitrary amounts of memory may be used by + * the buffers on the queue. The buffer contents must match the #GstCaps + * set in the :caps property. + */ +void +shell_recorder_src_add_buffer (ShellRecorderSrc *src, + GstBuffer *buffer) +{ + g_return_if_fail (SHELL_IS_RECORDER_SRC (src)); + g_return_if_fail (src->caps != NULL); + + gst_buffer_set_caps (buffer, src->caps); + shell_recorder_src_update_memory_used (src, + (int) (GST_BUFFER_SIZE(buffer) / 1024)); + + g_async_queue_push (src->queue, gst_buffer_ref (buffer)); +} + +/** + * shell_recorder_src_close: + * + * Indicates the end of the input stream. Once all previously added buffers have + * been pushed out an end-of-stream message will be sent. + */ +void +shell_recorder_src_close (ShellRecorderSrc *src) +{ + /* We can't send a message to the source immediately or buffers that haven't + * been pushed yet will be discarded. Instead stick a marker onto our own + * queue to send an event once everything has been pushed. + */ + g_async_queue_push (src->queue, RECORDER_QUEUE_END); +} + +static gboolean +plugin_init (GstPlugin *plugin) +{ + gst_element_register(plugin, "shellrecordersrc", GST_RANK_NONE, + SHELL_TYPE_RECORDER_SRC); + + return TRUE; +} + +/** + * shell_recorder_src_register: + * Registers a plugin holding our single element to use privately in + * this application. Can safely be called multiple times. + */ +void +shell_recorder_src_register (void) +{ + static gboolean registered = FALSE; + if (registered) + return; + + gst_plugin_register_static (GST_VERSION_MAJOR, GST_VERSION_MINOR, + "shellrecorder", + "Plugin for ShellRecorder", + plugin_init, + "0.1", + "LGPL", + "gnome-shell", "gnome-shell", "http://live.gnome.org/GnomeShell"); + + registered = TRUE; +} diff --git a/src/shell-recorder-src.h b/src/shell-recorder-src.h new file mode 100644 index 000000000..d4782202d --- /dev/null +++ b/src/shell-recorder-src.h @@ -0,0 +1,39 @@ +#ifndef __SHELL_RECORDER_SRC_H__ +#define __SHELL_RECORDER_SRC_H__ + +#include + +G_BEGIN_DECLS + +/** + * ShellRecorderSrc: + * + * shellrecordersrc a custom source element is pretty much like a very + * simple version of the stander GStreamer 'appsrc' element, without + * any of the provisions for seeking, generating data on demand, + * etc. In both cases, the application supplies the buffers and the + * element pushes them into the pipeline. The main reason for not using + * appsrc is that it wasn't a supported element until gstreamer 0.10.22, + * and as of 2009-03, many systems still have 0.10.21. + */ +typedef struct _ShellRecorderSrc ShellRecorderSrc; +typedef struct _ShellRecorderSrcClass ShellRecorderSrcClass; + +#define SHELL_TYPE_RECORDER_SRC (shell_recorder_src_get_type ()) +#define SHELL_RECORDER_SRC(object) (G_TYPE_CHECK_INSTANCE_CAST ((object), SHELL_TYPE_RECORDER_SRC, ShellRecorderSrc)) +#define SHELL_RECORDER_SRC_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), SHELL_TYPE_RECORDER_SRC, ShellRecorderSrcClass)) +#define SHELL_IS_RECORDER_SRC(object) (G_TYPE_CHECK_INSTANCE_TYPE ((object), SHELL_TYPE_RECORDER_SRC)) +#define SHELL_IS_RECORDER_SRC_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), SHELL_TYPE_RECORDER_SRC)) +#define SHELL_RECORDER_SRC_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), SHELL_TYPE_RECORDER_SRC, ShellRecorderSrcClass)) + +GType shell_recorder_src_get_type (void) G_GNUC_CONST; + +void shell_recorder_src_register (void); + +void shell_recorder_src_add_buffer (ShellRecorderSrc *src, + GstBuffer *buffer); +void shell_recorder_src_close (ShellRecorderSrc *src); + +G_END_DECLS + +#endif /* __SHELL_RECORDER_SRC_H__ */ diff --git a/src/shell-recorder.c b/src/shell-recorder.c new file mode 100644 index 000000000..b80b7db75 --- /dev/null +++ b/src/shell-recorder.c @@ -0,0 +1,1666 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +#include +#include +#include +#include + +#include + +#include "shell-recorder-src.h" +#include "shell-recorder.h" + +#include +#include + +typedef enum { + RECORDER_STATE_CLOSED, + RECORDER_STATE_PAUSED, + RECORDER_STATE_RECORDING +} RecorderState; + +typedef struct _RecorderPipeline RecorderPipeline; + +struct _ShellRecorderClass +{ + GObjectClass parent_class; +}; + +struct _ShellRecorder { + GObject parent; + + /* A "maximum" amount of memory to use for buffering. This is used + * to alert the user that they are filling up memory rather than + * any that actually affects recording. (In kB) + */ + guint memory_target; + guint memory_used; /* Current memory used. (In kB) */ + + RecorderState state; + char *unique; /* The unique string we are using for this recording */ + int count; /* How many times the recording has been started */ + + ClutterStage *stage; + int stage_width; + int stage_height; + + gboolean have_pointer; + int pointer_x; + int pointer_y; + + gboolean have_xfixes; + int xfixes_event_base; + + CoglHandle *recording_icon; /* icon shown while playing */ + + cairo_surface_t *cursor_image; + int cursor_hot_x; + int cursor_hot_y; + + gboolean only_paint; /* Used to temporarily suppress recording */ + + char *pipeline_description; + char *filename; + gboolean filename_has_count; /* %c used: handle pausing differently */ + + /* We might have multiple pipelines that are finishing encoding + * to go along with the current pipeline where we are recording. + */ + RecorderPipeline *current_pipeline; /* current pipeline */ + GSList *pipelines; /* all pipelines */ + + GstClockTime start_time; /* When we started recording (adjusted for pauses) */ + GstClockTime pause_time; /* When the pipeline was paused */ + + /* GSource IDs for different timeouts and idles */ + guint redraw_timeout; + guint redraw_idle; + guint update_memory_used_timeout; + guint update_pointer_timeout; +}; + +struct _RecorderPipeline +{ + ShellRecorder *recorder; + GstElement *pipeline; + GstElement *src; + int outfile; +}; + +static void recorder_set_stage (ShellRecorder *recorder, + ClutterStage *stage); +static void recorder_set_pipeline (ShellRecorder *recorder, + const char *pipeline); +static void recorder_set_filename (ShellRecorder *recorder, + const char *filename); + +static void recorder_pipeline_set_caps (RecorderPipeline *pipeline); +static void recorder_pipeline_closed (RecorderPipeline *pipeline); + +enum { + PROP_0, + PROP_STAGE, + PROP_PIPELINE, + PROP_FILENAME +}; + +G_DEFINE_TYPE(ShellRecorder, shell_recorder, G_TYPE_OBJECT); + +/* The number of frames per second we configure for the GStreamer pipeline. + * (the number of frames we actually write into the GStreamer pipeline is + * based entirely on how fast clutter is drawing.) Using 60fps seems high + * but the observed smoothness is a lot better than for 30fps when encoding + * as theora for a minimal size increase. This may be an artifact of the + * encoding process. + */ +#define FRAMES_PER_SECOND 15 + +/* The time (in milliseconds) between querying the server for the cursor + * position. + */ +#define UPDATE_POINTER_TIME 100 + +/* The time we wait (in milliseconds) before redrawing when the memory used + * changes. + */ +#define UPDATE_MEMORY_USED_DELAY 500 + +/* Maximum time between frames, in milliseconds. If we don't send data + * for a long period of time, then when we send the next frame, a lot + * of work can be created for the encoder to do, so we want to force a + * periodic redraw when nothing happen. + */ +#define MAXIMUM_PAUSE_TIME 1000 + +/* The default pipeline. videorate is used to give a constant stream of + * frames to theora even if there is a pause because nothing is moving. + * (Theora does have some support for frames at non-uniform times, but + * things seem to break down if there are large gaps.) + */ +#define DEFAULT_PIPELINE "videorate ! theoraenc ! oggmux" + +/* The default filename pattern. Example shell-20090311b-2.ogg + */ +#define DEFAULT_FILENAME "shell-%d%u-%c.ogg" + +/* If we can find the amount of memory on the machine, we use half + * of that for memory_target, otherwise, we use this value, in kB. + */ +#define DEFAULT_MEMORY_TARGET (512*1024) + +/* Create an emblem to show at the lower-left corner of the stage while + * recording. The emblem is drawn *after* we record the frame so doesn't + * show up in the frame. + */ +static CoglHandle * +create_recording_icon (void) +{ + cairo_surface_t *surface = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, 32, 32); + cairo_t *cr; + cairo_pattern_t *pat; + CoglHandle *texture; + + cr = cairo_create (surface); + + /* clear to transparent */ + cairo_save (cr); + cairo_set_operator (cr, CAIRO_OPERATOR_CLEAR); + cairo_paint (cr); + cairo_restore (cr); + + /* radial "glow" */ + pat = cairo_pattern_create_radial (16, 16, 6, + 16, 16, 14); + cairo_pattern_add_color_stop_rgba (pat, 0.0, + 1, 0, 0, 1); /* opaque red */ + cairo_pattern_add_color_stop_rgba (pat, 1.0, + 1, 0, 0, 0); /* transparent red */ + + cairo_set_source (cr, pat); + cairo_paint (cr); + cairo_pattern_destroy (pat); + + /* red circle */ + cairo_arc (cr, 16, 16, 8, + 0, 2 * M_PI); + cairo_set_source_rgb (cr, 1, 0, 0); + cairo_fill (cr); + + cairo_destroy (cr); + + texture = cogl_texture_new_from_data (32, 32, 63, + COGL_TEXTURE_NONE, + COGL_PIXEL_FORMAT_BGRA_8888, + COGL_PIXEL_FORMAT_ANY, + cairo_image_surface_get_stride (surface), + cairo_image_surface_get_data (surface)); + cairo_surface_destroy (surface); + + return texture; +} + +static guint +get_memory_target (void) +{ + FILE *f; + + /* Really simple "get amount of memory on the machine" if it + * doesn't work, you just get the default memory target. + */ + f = fopen("/proc/meminfo", "r"); + if (!f) + return DEFAULT_MEMORY_TARGET; + + while (!feof(f)) + { + gchar line_buffer[1024]; + guint mem_total; + if (fscanf(f, "MemTotal: %u", &mem_total) == 1) + { + fclose(f); + return mem_total / 2; + } + /* Skip to the next line and discard what we read */ + fgets(line_buffer, sizeof(line_buffer), f); + } + + fclose(f); + + return DEFAULT_MEMORY_TARGET; +} + +static void +shell_recorder_init (ShellRecorder *recorder) +{ + shell_recorder_src_register (); + + recorder->recording_icon = create_recording_icon (); + recorder->memory_target = get_memory_target(); + + recorder->state = RECORDER_STATE_CLOSED; +} + +static void +shell_recorder_finalize (GObject *object) +{ + ShellRecorder *recorder = SHELL_RECORDER (object); + GSList *l; + + for (l = recorder->pipelines; l; l = l->next) + { + RecorderPipeline *pipeline = l->data; + + /* Remove the back-reference. The pipeline will be freed + * when it finishes. (Or when the process exits, but that's + * out of our control.) + */ + pipeline->recorder = NULL; + } + + if (recorder->update_memory_used_timeout) + g_source_remove (recorder->update_memory_used_timeout); + + if (recorder->cursor_image) + cairo_surface_destroy (recorder->cursor_image); + + recorder_set_stage (recorder, NULL); + recorder_set_pipeline (recorder, NULL); + recorder_set_filename (recorder, NULL); + + cogl_texture_unref (recorder->recording_icon); + + G_OBJECT_CLASS (shell_recorder_parent_class)->finalize (object); +} + +static void +recorder_on_stage_destroy (ClutterActor *actor, + ShellRecorder *recorder) +{ + recorder_set_stage (recorder, NULL); +} + +/* Add together the memory used by all pipelines; both the + * currently recording pipeline and pipelines finishing + * recording asynchronously. + */ +static void +recorder_update_memory_used (ShellRecorder *recorder, + gboolean repaint) +{ + guint memory_used = 0; + GSList *l; + + for (l = recorder->pipelines; l; l = l->next) + { + RecorderPipeline *pipeline = l->data; + guint pipeline_memory_used; + + g_object_get (pipeline->src, + "memory-used", &pipeline_memory_used, + NULL); + memory_used += pipeline_memory_used; + } + + if (memory_used != recorder->memory_used) + { + recorder->memory_used = memory_used; + if (repaint) + { + /* In other cases we just queue a redraw even if we only need + * to repaint and not redraw a frame, but having changes in + * memory usage cause frames to be painted and memory used + * seems like a bad idea. + */ + recorder->only_paint = TRUE; + clutter_redraw (recorder->stage); + recorder->only_paint = FALSE; + } + } +} + +/* Timeout used to avoid not drawing for more than MAXIMUM_PAUSE_TIME + */ +static gboolean +recorder_redraw_timeout (gpointer data) +{ + ShellRecorder *recorder = data; + + recorder->redraw_timeout = 0; + clutter_actor_queue_redraw (CLUTTER_ACTOR (recorder->stage)); + + return FALSE; +} + +static void +recorder_add_redraw_timeout (ShellRecorder *recorder) +{ + if (recorder->redraw_timeout == 0) + { + recorder->redraw_timeout = g_timeout_add (MAXIMUM_PAUSE_TIME, + recorder_redraw_timeout, + recorder); + } +} + +static void +recorder_remove_redraw_timeout (ShellRecorder *recorder) +{ + if (recorder->redraw_timeout != 0) + { + g_source_remove (recorder->redraw_timeout); + recorder->redraw_timeout = 0; + } +} + +static void +recorder_fetch_cursor_image (ShellRecorder *recorder) +{ + XFixesCursorImage *cursor_image; + guchar *data; + int stride; + int i, j; + + if (!recorder->have_xfixes) + return; + + cursor_image = XFixesGetCursorImage (clutter_x11_get_default_display ()); + + recorder->cursor_hot_x = cursor_image->xhot; + recorder->cursor_hot_y = cursor_image->yhot; + + recorder->cursor_image = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, + cursor_image->width, + cursor_image->height); + + /* The pixel data (in typical Xlib breakage) is longs even on + * 64-bit platforms, so we have to data-convert there. For simplicity, + * just do it always + */ + data = cairo_image_surface_get_data (recorder->cursor_image); + stride = cairo_image_surface_get_stride (recorder->cursor_image); + for (i = 0; i < cursor_image->height; i++) + for (j = 0; j < cursor_image->width; j++) + *(guint32 *)(data + i * stride + 4 * j) = cursor_image->pixels[i * cursor_image->width + j]; +} + +/* Overlay the cursor image on the frame. We draw the cursor image + * into the host-memory buffer after we've captured the frame. An + * alternate approach would be to turn off the cursor while recording + * and draw the cursor ourselves with GL, but then we'd need to figure + * out what the cursor looks like, or hard-code a non-system cursor. + */ +static void +recorder_draw_cursor (ShellRecorder *recorder, + GstBuffer *buffer) +{ + cairo_surface_t *surface; + cairo_t *cr; + + /* We don't show a cursor unless the hot spot is in the frame; this + * means that sometimes we aren't going to draw a cursor even when + * there is a little bit overlapping within the stage */ + if (recorder->pointer_x < 0 || + recorder->pointer_y < 0 || + recorder->pointer_x >= recorder->stage_width || + recorder->pointer_y >= recorder->stage_height) + return; + + if (!recorder->cursor_image) + recorder_fetch_cursor_image (recorder); + + if (!recorder->cursor_image) + return; + + surface = cairo_image_surface_create_for_data (GST_BUFFER_DATA(buffer), + CAIRO_FORMAT_ARGB32, + recorder->stage_width, + recorder->stage_height, + recorder->stage_width * 4); + + /* The data we get from glReadPixels is "upside down", so transform + * our cairo drawing to match */ + cr = cairo_create (surface); + cairo_translate(cr, 0, recorder->stage_height); + cairo_scale(cr, 1, -1); + + cairo_set_source_surface (cr, + recorder->cursor_image, + recorder->pointer_x - recorder->cursor_hot_x, + recorder->pointer_y - recorder->cursor_hot_y); + cairo_paint (cr); + + cairo_destroy (cr); + cairo_surface_destroy (surface); +} + +/* Draw an overlay indicating how much of the target memory is used + * for buffering frames. + */ +static void +recorder_draw_buffer_meter (ShellRecorder *recorder) +{ + int fill_level; + + recorder_update_memory_used (recorder, FALSE); + + /* As the buffer gets more full, we go from green, to yellow, to red */ + if (recorder->memory_used > (recorder->memory_target * 3) / 4) + cogl_set_source_color4f (1, 0, 0, 1); + else if (recorder->memory_used > recorder->memory_target / 2) + cogl_set_source_color4f (1, 1, 0, 1); + else + cogl_set_source_color4f (0, 1, 0, 1); + + fill_level = MIN (60, (recorder->memory_used * 60) / recorder->memory_target); + + /* A hollow rectangle filled from the left to fill_level */ + cogl_rectangle (recorder->stage_width - 64, recorder->stage_height - 10, + recorder->stage_width - 2, recorder->stage_height - 9); + cogl_rectangle (recorder->stage_width - 64, recorder->stage_height - 9, + recorder->stage_width - (63 - fill_level), recorder->stage_height - 3); + cogl_rectangle (recorder->stage_width - 3, recorder->stage_height - 9, + recorder->stage_width - 2, recorder->stage_height - 3); + cogl_rectangle (recorder->stage_width - 64, recorder->stage_height - 3, + recorder->stage_width - 2, recorder->stage_height - 2); +} + +/* We want to time-stamp each frame based on the actual time it was + * recorded. We probably should use the pipeline clock rather than + * gettimeofday(): that would be needed to get sync'ed audio correct. + * I'm not immediately sure how to handle the adjustment we currently + * do when pausing recording - is pausing the pipeline enough? + */ +static GstClockTime +get_wall_time (void) +{ + GTimeVal tv; + + g_get_current_time (&tv); + + return tv.tv_sec * 1000000000LL + tv.tv_usec * 1000LL; +} + +/* Retrieve a frame and feed it into the pipeline + */ +static void +recorder_record_frame (ShellRecorder *recorder) +{ + GstBuffer *buffer; + guint8 *data; + guint size; + + size = recorder->stage_width * recorder->stage_height * 4; + data = g_malloc (size); + + buffer = gst_buffer_new(); + GST_BUFFER_SIZE(buffer) = size; + GST_BUFFER_MALLOCDATA(buffer) = GST_BUFFER_DATA(buffer) = data; + + GST_BUFFER_TIMESTAMP(buffer) = get_wall_time() - recorder->start_time; + + glReadBuffer (GL_BACK_LEFT); + glReadPixels (0, 0, + recorder->stage_width, recorder->stage_height, + GL_BGRA, + GL_UNSIGNED_INT_8_8_8_8_REV, + data); + + recorder_draw_cursor (recorder, buffer); + + shell_recorder_src_add_buffer (SHELL_RECORDER_SRC (recorder->current_pipeline->src), buffer); + gst_buffer_unref (buffer); + + /* Reset the timeout that we used to avoid an overlong pause in the stream */ + recorder_remove_redraw_timeout (recorder); + recorder_add_redraw_timeout (recorder); +} + +/* We hook in by recording each frame right after the stage is painted + * by clutter before glSwapBuffers() makes it visible to the user. + */ +static void +recorder_on_stage_paint (ClutterActor *actor, + ShellRecorder *recorder) +{ + if (recorder->state == RECORDER_STATE_RECORDING) + { + if (!recorder->only_paint) + recorder_record_frame (recorder); + + cogl_set_source_texture (recorder->recording_icon); + cogl_rectangle (recorder->stage_width - 32, recorder->stage_height - 42, + recorder->stage_width, recorder->stage_height - 10); + } + + if (recorder->state == RECORDER_STATE_RECORDING || recorder->memory_used != 0) + recorder_draw_buffer_meter (recorder); +} + +static void +recorder_update_size (ShellRecorder *recorder) +{ + ClutterActorBox allocation; + + clutter_actor_get_allocation_box (CLUTTER_ACTOR (recorder->stage), &allocation); + recorder->stage_width = (int)(0.5 + allocation.x2 - allocation.x1); + recorder->stage_height = (int)(0.5 + allocation.y2 - allocation.y1); +} + +static void +recorder_on_stage_notify_size (GObject *object, + GParamSpec *pspec, + ShellRecorder *recorder) +{ + recorder_update_size (recorder); + + /* This breaks the recording but tweaking the GStreamer pipeline a bit + * might make it work, at least if the codec can handle a stream where + * the frame size changes in the middle. + */ + if (recorder->current_pipeline) + recorder_pipeline_set_caps (recorder->current_pipeline); +} + +static gboolean +recorder_idle_redraw (gpointer data) +{ + ShellRecorder *recorder = data; + + recorder->redraw_idle = 0; + clutter_actor_queue_redraw (CLUTTER_ACTOR (recorder->stage)); + + return FALSE; +} + +static void +recorder_queue_redraw (ShellRecorder *recorder) +{ + /* If we just queue a redraw on every mouse motion (for example), we + * starve ClutterTimeline, which operates at a very low priority. So + * we need to queue a "low priority redraw" after timeline updates + */ + if (recorder->state == RECORDER_STATE_RECORDING && recorder->redraw_idle == 0) + recorder->redraw_idle = g_idle_add_full (CLUTTER_PRIORITY_TIMELINE + 1, + recorder_idle_redraw, recorder, NULL); +} + +/* We use an event filter on the stage to get the XFixesCursorNotifyEvent + * and also to track cursor position (when the cursor is over the stage's + * input area); tracking cursor position here rather than with ClutterEvent + * allows us to avoid worrying about event propagation and competing + * signal handlers. + */ +static ClutterX11FilterReturn +recorder_event_filter (XEvent *xev, + ClutterEvent *cev, + gpointer data) +{ + ShellRecorder *recorder = data; + + if (xev->xany.window != clutter_x11_get_stage_window (recorder->stage)) + return CLUTTER_X11_FILTER_CONTINUE; + + if (xev->xany.type == recorder->xfixes_event_base + XFixesCursorNotify) + { + XFixesCursorNotifyEvent *notify_event = (XFixesCursorNotifyEvent *)xev; + + if (notify_event->subtype == XFixesDisplayCursorNotify) + { + if (recorder->cursor_image) + { + cairo_surface_destroy (recorder->cursor_image); + recorder->cursor_image = NULL; + } + + recorder_queue_redraw (recorder); + } + } + else if (xev->xany.type == MotionNotify) + { + recorder->pointer_x = xev->xmotion.x; + recorder->pointer_y = xev->xmotion.y; + + recorder_queue_redraw (recorder); + } + /* We want to track whether the pointer is over the stage + * window itself, and not in a child window. A "virtual" + * crossing is one that goes directly from ancestor to child. + */ + else if (xev->xany.type == EnterNotify && + (xev->xcrossing.detail != NotifyVirtual && + xev->xcrossing.detail != NotifyNonlinearVirtual)) + { + recorder->have_pointer = TRUE; + recorder->pointer_x = xev->xcrossing.x; + recorder->pointer_y = xev->xcrossing.y; + + recorder_queue_redraw (recorder); + } + else if (xev->xany.type == LeaveNotify && + (xev->xcrossing.detail != NotifyVirtual && + xev->xcrossing.detail != NotifyNonlinearVirtual)) + { + recorder->have_pointer = FALSE; + recorder->pointer_x = xev->xcrossing.x; + recorder->pointer_y = xev->xcrossing.y; + + recorder_queue_redraw (recorder); + } + + return CLUTTER_X11_FILTER_CONTINUE; +} + +/* We optimize out querying the server for the pointer position if the + * pointer is in the input area of the ClutterStage. We track changes to + * that with Enter/Leave events, but we need to 100% accurate about the + * initial condition, which is a little involved. + */ +static void +recorder_get_initial_cursor_position (ShellRecorder *recorder) +{ + Display *xdisplay = clutter_x11_get_default_display (); + Window xwindow = clutter_x11_get_stage_window (recorder->stage); + XWindowAttributes xwa; + Window root, child, parent; + Window *children; + guint n_children; + int root_x,root_y; + int window_x, window_y; + guint mask; + + XGrabServer(xdisplay); + + XGetWindowAttributes (xdisplay, xwindow, &xwa); + XQueryTree (xdisplay, xwindow, &root, &parent, &children, &n_children); + XFree (children); + + if (xwa.map_state == IsViewable && + XQueryPointer (xdisplay, parent, + &root, &child, &root_x, &root_y, &window_x, &window_y, &mask) && + child == xwindow) + { + /* The point of this call is not actually to translate the coordinates - + * we could do that ourselves using xwa.{x,y} - but rather to see if + * the pointer is in a child of the window, which we count as "not in + * window", because we aren't guaranteed to get pointer events. + */ + XTranslateCoordinates(xdisplay, parent, xwindow, + window_x, window_y, + &window_x, &window_y, &child); + if (child == None) + { + recorder->have_pointer = TRUE; + recorder->pointer_x = window_x; + recorder->pointer_y = window_y; + } + } + else + recorder->have_pointer = FALSE; + + XUngrabServer(xdisplay); + XFlush(xdisplay); + + /* While we are at it, add mouse events to the event mask; they will + * be there for the stage windows that Clutter creates by default, but + * maybe this stage was created differently. Since we've already + * retrieved the event mask, it's almost free. + */ + XSelectInput(xdisplay, xwindow, + xwa.your_event_mask | EnterWindowMask | LeaveWindowMask | PointerMotionMask); +} + +/* When the cursor is not over the stage's input area, we query for the + * pointer position in a timeout. + */ +static void +recorder_update_pointer (ShellRecorder *recorder) +{ + Display *xdisplay = clutter_x11_get_default_display (); + Window xwindow = clutter_x11_get_stage_window (recorder->stage); + Window root, child; + int root_x,root_y; + int window_x, window_y; + guint mask; + + if (recorder->have_pointer) + return; + + if (XQueryPointer (xdisplay, xwindow, + &root, &child, &root_x, &root_y, &window_x, &window_y, &mask)) + { + if (window_x != recorder->pointer_x || window_y != recorder->pointer_y) + { + recorder->pointer_x = window_x; + recorder->pointer_y = window_y; + + recorder_queue_redraw (recorder); + } + } +} + +static gboolean +recorder_update_pointer_timeout (gpointer data) +{ + recorder_update_pointer (data); + + return TRUE; +} + +static void +recorder_add_update_pointer_timeout (ShellRecorder *recorder) +{ + if (!recorder->update_pointer_timeout) + recorder->update_pointer_timeout = g_timeout_add (UPDATE_POINTER_TIME, + recorder_update_pointer_timeout, + recorder); +} + +static void +recorder_remove_update_pointer_timeout (ShellRecorder *recorder) +{ + if (recorder->update_pointer_timeout) + { + g_source_remove (recorder->update_pointer_timeout); + recorder->update_pointer_timeout = 0; + } +} + +static void +recorder_set_stage (ShellRecorder *recorder, + ClutterStage *stage) +{ + if (recorder->stage == stage) + return; + + if (recorder->current_pipeline) + shell_recorder_close (recorder); + + if (recorder->stage) + { + g_signal_handlers_disconnect_by_func (recorder->stage, + (void *)recorder_on_stage_destroy, + recorder); + g_signal_handlers_disconnect_by_func (recorder->stage, + (void *)recorder_on_stage_paint, + recorder); + g_signal_handlers_disconnect_by_func (recorder->stage, + (void *)recorder_on_stage_notify_size, + recorder); + + clutter_x11_remove_filter (recorder_event_filter, recorder); + + /* We don't don't deselect for cursor changes in case someone else just + * happened to be selecting for cursor events on the same window; sending + * us the events is close to free in any case. + */ + + if (recorder->redraw_idle) + { + g_source_remove (recorder->redraw_idle); + recorder->redraw_idle = 0; + } + } + + recorder->stage = stage; + + if (recorder->stage) + { + int error_base; + + recorder->stage = stage; + g_signal_connect (recorder->stage, "destroy", + G_CALLBACK (recorder_on_stage_destroy), recorder); + g_signal_connect_after (recorder->stage, "paint", + G_CALLBACK (recorder_on_stage_paint), recorder); + g_signal_connect (recorder->stage, "notify::width", + G_CALLBACK (recorder_on_stage_notify_size), recorder); + g_signal_connect (recorder->stage, "notify::width", + G_CALLBACK (recorder_on_stage_notify_size), recorder); + + clutter_x11_add_filter (recorder_event_filter, recorder); + + recorder_update_size (recorder); + + recorder->have_xfixes = XFixesQueryExtension (clutter_x11_get_default_display (), + &recorder->xfixes_event_base, + &error_base); + if (recorder->have_xfixes) + XFixesSelectCursorInput (clutter_x11_get_default_display (), + clutter_x11_get_stage_window (stage), + XFixesDisplayCursorNotifyMask); + + recorder_get_initial_cursor_position (recorder); + } +} + +static void +recorder_set_pipeline (ShellRecorder *recorder, + const char *pipeline) +{ + if (pipeline == recorder->pipeline_description || + (pipeline && recorder->pipeline_description && strcmp (recorder->pipeline_description, pipeline) == 0)) + return; + + if (recorder->current_pipeline) + shell_recorder_close (recorder); + + if (recorder->pipeline_description) + g_free (recorder->pipeline_description); + + recorder->pipeline_description = g_strdup (pipeline); + + g_object_notify (G_OBJECT (recorder), "pipeline"); +} + +static void +recorder_set_filename (ShellRecorder *recorder, + const char *filename) +{ + if (filename == recorder->filename || + (filename && recorder->filename && strcmp (recorder->filename, filename) == 0)) + return; + + if (recorder->current_pipeline) + shell_recorder_close (recorder); + + if (recorder->filename) + g_free (recorder->filename); + + recorder->filename = g_strdup (filename); + + g_object_notify (G_OBJECT (recorder), "filename"); +} + +static void +shell_recorder_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + ShellRecorder *recorder = SHELL_RECORDER (object); + + switch (prop_id) + { + case PROP_STAGE: + recorder_set_stage (recorder, g_value_get_object (value)); + break; + case PROP_PIPELINE: + recorder_set_pipeline (recorder, g_value_get_string (value)); + break; + case PROP_FILENAME: + recorder_set_filename (recorder, g_value_get_string (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +shell_recorder_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + ShellRecorder *recorder = SHELL_RECORDER (object); + + switch (prop_id) + { + case PROP_STAGE: + g_value_set_object (value, G_OBJECT (recorder->stage)); + break; + case PROP_PIPELINE: + g_value_set_string (value, recorder->pipeline_description); + break; + case PROP_FILENAME: + g_value_set_string (value, recorder->filename); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +shell_recorder_class_init (ShellRecorderClass *klass) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS (klass); + + gobject_class->finalize = shell_recorder_finalize; + gobject_class->get_property = shell_recorder_get_property; + gobject_class->set_property = shell_recorder_set_property; + + g_object_class_install_property (gobject_class, + PROP_STAGE, + g_param_spec_object ("stage", + "Stage", + "Stage to record", + CLUTTER_TYPE_STAGE, + G_PARAM_READWRITE)); + g_object_class_install_property (gobject_class, + PROP_PIPELINE, + g_param_spec_string ("pipeline", + "Pipeline", + "GStreamer pipeline description to encode recordings", + NULL, + G_PARAM_READWRITE)); + + g_object_class_install_property (gobject_class, + PROP_FILENAME, + g_param_spec_string ("filename", + "Filename", + "The filename template to use for output files", + NULL, + G_PARAM_READWRITE)); +} + +/* Sets the GstCaps (video format, in this case) on the stream + */ +static void +recorder_pipeline_set_caps (RecorderPipeline *pipeline) +{ + GstCaps *caps; + + /* The data is always native-endian xRGB; ffmpegcolorspace + * doesn't support little-endian xRGB, but does support + * big-endian BGRx. + */ + caps = gst_caps_new_simple ("video/x-raw-rgb", + "bpp", G_TYPE_INT, 32, + "depth", G_TYPE_INT, 24, +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + "red_mask", G_TYPE_INT, 0x0000ff00, + "green_mask", G_TYPE_INT, 0x00ff0000, + "blue_mask", G_TYPE_INT, 0xff000000, +#else + "red_mask", G_TYPE_INT, 0xff0000, + "green_mask", G_TYPE_INT, 0x00ff00, + "blue_mask", G_TYPE_INT, 0x0000ff, +#endif + "endianness", G_TYPE_INT, G_BIG_ENDIAN, + "framerate", GST_TYPE_FRACTION, FRAMES_PER_SECOND, 1, + "width", G_TYPE_INT, pipeline->recorder->stage_width, + "height", G_TYPE_INT, pipeline->recorder->stage_height, + NULL); + g_object_set (pipeline->src, "caps", caps, NULL); + gst_caps_unref (caps); +} + +/* Augments the supplied pipeline with the source elements: the actual + * ShellRecorderSrc element where we inject frames then additional elements + * to convert the output into something palatable. + */ +static gboolean +recorder_pipeline_add_source (RecorderPipeline *pipeline) +{ + GstPad *sink_pad = NULL, *src_pad = NULL; + gboolean result = FALSE; + GstElement *ffmpegcolorspace; + GstElement *videoflip; + GError *error = NULL; + + sink_pad = gst_bin_find_unlinked_pad (GST_BIN (pipeline->pipeline), GST_PAD_SINK); + if (sink_pad == NULL) + { + g_warning("ShellRecorder: pipeline has no unlinked sink pad"); + goto out; + } + + pipeline->src = gst_element_factory_make ("shellrecordersrc", NULL); + if (pipeline->src == NULL) + { + g_warning ("Can't create recorder source element"); + goto out; + } + gst_bin_add (GST_BIN (pipeline->pipeline), pipeline->src); + + recorder_pipeline_set_caps (pipeline); + + /* The ffmpegcolorspace element is a generic converter; it will convert + * our supplied fixed format data into whatever the encoder wants + */ + ffmpegcolorspace = gst_element_factory_make ("ffmpegcolorspace", NULL); + if (!ffmpegcolorspace) + { + g_warning("Can't create ffmpegcolorspace element"); + goto out; + } + gst_bin_add (GST_BIN (pipeline->pipeline), ffmpegcolorspace); + + /* glReadPixels gives us an upside-down buffer, so we have to flip it back + * right-side up. We do this after the color space conversion in the theory + * that we might have a smaller buffer to flip; on the other hand flipping + * YUV 422 is more complicated than flipping RGB. Probably a toss-up. + * + * We use gst_parse_launch to avoid having to know the enum value for flip-vertical + */ + videoflip = gst_parse_launch_full ("videoflip method=vertical-flip", NULL, + GST_PARSE_FLAG_FATAL_ERRORS, + &error); + if (videoflip == NULL) + { + g_warning("Can't create videoflip element: %s", error->message); + g_error_free (error); + goto out; + } + gst_bin_add (GST_BIN (pipeline->pipeline), videoflip); + + gst_element_link_many (pipeline->src, ffmpegcolorspace, videoflip, + NULL); + + src_pad = gst_element_get_static_pad (videoflip, "src"); + if (!src_pad) + { + g_warning("ShellRecorder: can't get src pad to link into pipeline"); + goto out; + } + + if (gst_pad_link (src_pad, sink_pad) != GST_PAD_LINK_OK) + { + g_warning("ShellRecorder: can't link to sink pad"); + goto out; + } + + result = TRUE; + + out: + if (sink_pad) + gst_object_unref (sink_pad); + if (src_pad) + gst_object_unref (src_pad); + + return result; +} + +/* Counts '', 'a', ..., 'z', 'aa', ..., 'az', 'ba', ... */ +static void +increment_unique (GString *unique) +{ + int i; + + for (i = unique->len - 1; i >= 0; i--) + { + if (unique->str[i] != 'z') + { + unique->str[i]++; + return; + } + else + unique->str[i] = 'a'; + } + + g_string_prepend_c (unique, 'a'); +} + +static char * +get_absolute_path (char *maybe_relative) +{ + char *path; + + if (g_path_is_absolute (maybe_relative)) + path = g_strdup (maybe_relative); + else + { + char *cwd = g_get_current_dir (); + path = g_build_filename (cwd, maybe_relative, NULL); + g_free (cwd); + } + + return path; +} + +/* Open a file for writing. Opening the file ourselves and using fdsink has + * the advantage over filesink of being able to use O_EXCL when we want to + * avoid overwriting* an existing file. Returns -1 if the file couldn't + * be opened. + */ +static int +recorder_open_outfile (ShellRecorder *recorder) +{ + GString *unique = g_string_new (NULL); /* add to filename to make it unique */ + const char *pattern; + int flags; + int outfile = -1; + + recorder->count++; + + pattern = recorder->filename; + if (!pattern) + pattern = DEFAULT_FILENAME; + + while (TRUE) + { + GString *filename = g_string_new (NULL); + const char *p; + + for (p = pattern; *p; p++) + { + if (*p == '%') + { + switch (*(p + 1)) + { + case '%': + case '\0': + g_string_append_c (filename, '%'); + break; + case 'c': + { + /* Count distinguishing multiple files created in session */ + g_string_append_printf (filename, "%d", recorder->count); + recorder->filename_has_count = TRUE; + } + break; + case 'd': + { + /* Appends date as YYYYMMDD */ + GDate date; + GTimeVal now; + g_get_current_time (&now); + g_date_clear (&date, 1); + g_date_set_time_val (&date, &now); + g_string_append_printf (filename, "%04d%02d%02d", + g_date_get_year (&date), + g_date_get_month (&date), + g_date_get_day (&date)); + } + break; + case 'u': + if (recorder->unique) + g_string_append (filename, recorder->unique); + else + g_string_append (filename, unique->str); + break; + default: + g_warning ("Unknown escape %%%c in filename", *p); + goto out; + } + + p++; + } + else + g_string_append_c (filename, *p); + } + + /* If a filename is explicitly specified without %u then we assume the user + * is fine with over-writing the old contents; putting %u in the default + * should avoid problems with malicious symlinks. + */ + flags = O_WRONLY | O_CREAT | O_TRUNC; + if (recorder->filename_has_count) + flags |= O_EXCL; + + outfile = open (filename->str, flags, 0666); + if (outfile != -1) + { + char *path = get_absolute_path (filename->str); + g_printerr ("Recording to %s\n", path); + g_free (path); + + g_string_free (filename, TRUE); + goto out; + } + + if (outfile == -1 && + (errno != EEXIST || !recorder->filename_has_count)) + { + g_warning ("Cannot open output file '%s': %s", filename->str, g_strerror (errno)); + g_string_free (filename, TRUE); + goto out; + } + + if (recorder->unique) + { + /* We've already picked a unique string based on count=1, and now we had a collision + * for a subsequent count. + */ + g_warning ("Name collision with existing file for '%s'", filename->str); + g_string_free (filename, TRUE); + goto out; + } + + g_string_free (filename, TRUE); + + increment_unique (unique); + } + + out: + if (outfile != -1) + recorder->unique = g_string_free (unique, FALSE); + else + g_string_free (unique, TRUE); + + return outfile; +} + +/* Augments the supplied pipeline with a sink element to write to the output + * file, if necessary. + */ +static gboolean +recorder_pipeline_add_sink (RecorderPipeline *pipeline) +{ + GstPad *sink_pad = NULL, *src_pad = NULL; + GstElement *fdsink; + gboolean result = FALSE; + + src_pad = gst_bin_find_unlinked_pad (GST_BIN (pipeline->pipeline), GST_PAD_SRC); + if (src_pad == NULL) + { + /* Nothing to do - assume that we were given a complete pipeline */ + return TRUE; + } + + pipeline->outfile = recorder_open_outfile (pipeline->recorder); + if (pipeline->outfile == -1) + goto out; + + fdsink = gst_element_factory_make ("fdsink", NULL); + if (fdsink == NULL) + { + g_warning("Can't create fdsink element"); + goto out; + } + gst_bin_add (GST_BIN (pipeline->pipeline), fdsink); + g_object_set (fdsink, "fd", pipeline->outfile, NULL); + + sink_pad = gst_element_get_static_pad (fdsink, "sink"); + if (!sink_pad) + { + g_warning("ShellRecorder: can't get sink pad to link pipeline output"); + goto out; + } + + if (gst_pad_link (src_pad, sink_pad) != GST_PAD_LINK_OK) + { + g_warning("ShellRecorder: can't link to sink pad"); + goto out; + } + + result = TRUE; + + out: + if (src_pad) + gst_object_unref (src_pad); + if (sink_pad) + gst_object_unref (sink_pad); + + return result; +} + +static gboolean +recorder_update_memory_used_timeout (gpointer data) +{ + ShellRecorder *recorder = data; + recorder->update_memory_used_timeout = 0; + + recorder_update_memory_used (recorder, TRUE); + + return FALSE; +} + +/* We throttle down the frequency which we recompute memory usage + * and draw the buffer indicator to avoid cutting into performance. + */ +static void +recorder_pipeline_on_memory_used_changed (ShellRecorderSrc *src, + GParamSpec *spec, + RecorderPipeline *pipeline) +{ + ShellRecorder *recorder = pipeline->recorder; + if (!recorder) + return; + + if (recorder->update_memory_used_timeout == 0) + recorder->update_memory_used_timeout = g_timeout_add (UPDATE_MEMORY_USED_DELAY, + recorder_update_memory_used_timeout, + recorder); +} + +static void +recorder_pipeline_free (RecorderPipeline *pipeline) +{ + if (pipeline->pipeline != NULL) + gst_object_unref (pipeline->pipeline); + + if (pipeline->outfile != -1) + close (pipeline->outfile); + + g_free (pipeline); +} + +/* Function gets called on pipeline-global events; we use it to + * know when the pipeline is finished. + */ +static gboolean +recorder_pipeline_bus_watch (GstBus *bus, + GstMessage *message, + gpointer data) +{ + RecorderPipeline *pipeline = data; + + switch (message->type) + { + case GST_MESSAGE_EOS: + recorder_pipeline_closed (pipeline); + return FALSE; /* remove watch */ + case GST_MESSAGE_ERROR: + { + GError *error; + + gst_message_parse_error (message, &error, NULL); + g_warning ("Error in recording pipeline: %s\n", error->message); + g_error_free (error); + recorder_pipeline_closed (pipeline); + return FALSE; /* remove watch */ + } + default: + break; + } + + /* Leave the watch in place */ + return TRUE; +} + +/* Clean up when the pipeline is finished + */ +static void +recorder_pipeline_closed (RecorderPipeline *pipeline) +{ + g_signal_handlers_disconnect_by_func (pipeline->src, + (gpointer) recorder_pipeline_on_memory_used_changed, + pipeline); + + gst_element_set_state (pipeline->pipeline, GST_STATE_NULL); + + if (pipeline->recorder) + { + ShellRecorder *recorder = pipeline->recorder; + if (pipeline == recorder->current_pipeline) + { + /* Error case; force a close */ + recorder->current_pipeline = NULL; + shell_recorder_close (recorder); + } + + recorder->pipelines = g_slist_remove (recorder->pipelines, pipeline); + } + + recorder_pipeline_free (pipeline); +} + +static gboolean +recorder_open_pipeline (ShellRecorder *recorder) +{ + RecorderPipeline *pipeline; + const char *pipeline_description; + GError *error = NULL; + GstBus *bus; + + pipeline = g_new0(RecorderPipeline, 1); + pipeline->recorder = recorder; + pipeline->outfile = - 1; + + pipeline_description = recorder->pipeline_description; + if (!pipeline_description) + pipeline_description = DEFAULT_PIPELINE; + + pipeline->pipeline = gst_parse_launch_full (pipeline_description, NULL, + GST_PARSE_FLAG_FATAL_ERRORS, + &error); + + if (pipeline->pipeline == NULL) + { + g_warning ("ShellRecorder: failed to parse pipeline: %s", error->message); + g_error_free (error); + goto error; + } + + if (!recorder_pipeline_add_source (pipeline)) + goto error; + + if (!recorder_pipeline_add_sink (pipeline)) + goto error; + + gst_element_set_state (pipeline->pipeline, GST_STATE_PLAYING); + + bus = gst_pipeline_get_bus (GST_PIPELINE (pipeline->pipeline)); + gst_bus_add_watch (bus, recorder_pipeline_bus_watch, pipeline); + gst_object_unref (bus); + + g_signal_connect (pipeline->src, "notify::memory-used", + G_CALLBACK (recorder_pipeline_on_memory_used_changed), pipeline); + + recorder->current_pipeline = pipeline; + recorder->pipelines = g_slist_prepend (recorder->pipelines, pipeline); + + return TRUE; + + error: + recorder_pipeline_free (pipeline); + + return FALSE; +} + +static void +recorder_close_pipeline (ShellRecorder *recorder) +{ + if (recorder->current_pipeline != NULL) + { + /* This will send an EOS (end-of-stream) message after the last frame + * is written. The bus watch for the pipeline will get it and do + * final cleanup + */ + shell_recorder_src_close (SHELL_RECORDER_SRC (recorder->current_pipeline->src)); + + recorder->current_pipeline = NULL; + recorder->filename_has_count = FALSE; + } +} + +/** + * shell_recorder_new: + * @stage: The #ClutterStage + * + * Create a new #ShellRecorder to record movies of a #ClutterStage + * + * Return value: The newly created #ShellRecorder object + */ +ShellRecorder * +shell_recorder_new (ClutterStage *stage) +{ + return g_object_new (SHELL_TYPE_RECORDER, + "stage", stage, + NULL); +} + +/** + * shell_recorder_set_filename: + * @recorder: the #ShellRecorder + * @filename: the filename template to use for output files, + * or %NULL for the defalt value. + * + * Sets the filename that will be used when creating output + * files. This is only used if the configured pipeline has an + * unconnected source pad (as the default pipeline does). If + * the pipeline is complete, then the filename is unused. The + * provided string is used as a template.It can contain + * the following escapes: + * + * %d: The current date as YYYYYMMDD + * %u: A string added to make the filename unique. + * '', 'a', 'b', ... 'aa', 'ab', .. + * %c: A counter that is updated (opening a new file) each + * time the recording stream is paused. + * %%: A literal percent + * + * The default value is 'shell-%d%u-%c.ogg'. + */ +void +shell_recorder_set_filename (ShellRecorder *recorder, + const char *filename) +{ + g_return_if_fail (SHELL_IS_RECORDER (recorder)); + + recorder_set_filename (recorder, filename); + +} + +/** + * shell_recorder_set_pipeline: + * @recorder: the #ShellRecorder + * @filename: the GStreamer pipeline used to encode recordings + * or %NULL for the defalt value. + * + * Sets the GStreamer pipeline used to encode recordings. + * It follows the syntax used for gst-launch. The pipeline + * should have an unconnected sink pad where the recorded + * video is recorded. It will normally have a unconnected + * source pad; output from that pad will be written into the + * output file. (See shell_recorder_set_filename().) However + * the pipeline can also take care of its own output - this + * might be used to send the output to an icecast server + * via shout2send or similar. + * + * The default value is 'videorate ! theoraenc ! oggmux' + */ +void +shell_recorder_set_pipeline (ShellRecorder *recorder, + const char *pipeline) +{ + g_return_if_fail (SHELL_IS_RECORDER (recorder)); + + recorder_set_pipeline (recorder, pipeline); +} + +/** + * shell_recorder_record: + * @recorder: the #ShellRecorder + * + * Starts recording, or continues a recording that was previously + * paused. Starting the recording may fail if the output file + * cannot be opened, or if the output stream cannot be created + * for other reasons. In that case a warning is printed to + * stderr. There is no way currently to get details on how + * recording failed to start. + * + * An extra reference count is added to the recorder if recording + * is succesfully started; the recording object will not be freed + * until recording is stopped even if the creator no longer holds + * a reference. Recording is automatically stopped if the stage + * is destroyed. + * + * Return value: %TRUE if recording was succesfully started + */ +gboolean +shell_recorder_record (ShellRecorder *recorder) +{ + g_return_val_if_fail (SHELL_IS_RECORDER (recorder), FALSE); + g_return_val_if_fail (recorder->stage != NULL, FALSE); + g_return_val_if_fail (recorder->state != RECORDER_STATE_RECORDING, FALSE); + + if (recorder->current_pipeline) + { + /* Adjust the start time so that the times in the stream ignore the + * pause + */ + recorder->start_time = recorder->start_time + (get_wall_time() - recorder->pause_time); + } + else + { + if (!recorder_open_pipeline (recorder)) + return FALSE; + + recorder->start_time = get_wall_time(); + } + + recorder->state = RECORDER_STATE_RECORDING; + recorder_add_update_pointer_timeout (recorder); + + /* Record an initial frame and also redraw with the indicator */ + clutter_actor_queue_redraw (CLUTTER_ACTOR (recorder->stage)); + + /* We keep a ref while recording to let a caller start a recording then + * drop their reference to the recorder + */ + g_object_ref (recorder); + + return TRUE; +} + +/** + * shell_recorder_pause: + * @recorder: the #ShellRecorder + * + * Temporarily stop recording. If the specified filename includes + * the %c escape, then the stream is closed and a new stream with + * an incremented counter will be created. Otherwise the stream + * is paused and will be continued when shell_recorder_record() + * is next called. + */ +void +shell_recorder_pause (ShellRecorder *recorder) +{ + g_return_if_fail (SHELL_IS_RECORDER (recorder)); + g_return_if_fail (recorder->state == RECORDER_STATE_RECORDING); + + recorder_remove_update_pointer_timeout (recorder); + /* We want to record one more frame since some time may have + * elapsed since the last frame + */ + clutter_actor_paint (CLUTTER_ACTOR (recorder->stage)); + + if (recorder->filename_has_count) + recorder_close_pipeline (recorder); + + recorder->state = RECORDER_STATE_PAUSED; + recorder->pause_time = get_wall_time(); + + /* Queue a redraw to remove the recording indicator */ + clutter_actor_queue_redraw (CLUTTER_ACTOR (recorder->stage)); +} + +/** + * shell_recorder_close: + * @recorder: the #ShellRecorder + * + * Stops recording. It's possible to call shell_recorder_record() + * again to reopen a new recording stream, but unless change the + * recording filename, this may result in the old recording being + * overwritten. + */ +void +shell_recorder_close (ShellRecorder *recorder) +{ + g_return_if_fail (SHELL_IS_RECORDER (recorder)); + g_return_if_fail (recorder->state != RECORDER_STATE_CLOSED); + + if (recorder->state == RECORDER_STATE_RECORDING) + shell_recorder_pause (recorder); + + recorder_remove_update_pointer_timeout (recorder); + recorder_remove_redraw_timeout (recorder); + recorder_close_pipeline (recorder); + + recorder->state = RECORDER_STATE_CLOSED; + recorder->count = 0; + g_free (recorder->unique); + recorder->unique = NULL; + + /* Release the refcount we took when we started recording */ + g_object_unref (recorder); +} + +/** + * shell_recorder_is_recording: + * + * Determine if recording is currently in progress. (The recorder + * is not paused or closed.) + * + * Return value: %TRUE if the recorder is currently recording. + */ +gboolean +shell_recorder_is_recording (ShellRecorder *recorder) +{ + g_return_val_if_fail (SHELL_IS_RECORDER (recorder), FALSE); + + return recorder->state == RECORDER_STATE_RECORDING; +} diff --git a/src/shell-recorder.h b/src/shell-recorder.h new file mode 100644 index 000000000..359e5288b --- /dev/null +++ b/src/shell-recorder.h @@ -0,0 +1,43 @@ +#ifndef __SHELL_RECORDER_H__ +#define __SHELL_RECORDER_H__ + +#include + +G_BEGIN_DECLS + +/** + * SECTION:ShellRecorder + * short_description: Record from a #ClutterStage + * + * The #ShellRecorder object is used to make recordings ("screencasts") + * of a #ClutterStage. Recording is done via #GStreamer. The default is + * to encode as a Theora movie and write it to a file in the current + * directory named after the date, but the encoding and output can + * be configured. + */ +typedef struct _ShellRecorder ShellRecorder; +typedef struct _ShellRecorderClass ShellRecorderClass; + +#define SHELL_TYPE_RECORDER (shell_recorder_get_type ()) +#define SHELL_RECORDER(object) (G_TYPE_CHECK_INSTANCE_CAST ((object), SHELL_TYPE_RECORDER, ShellRecorder)) +#define SHELL_RECORDER_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), SHELL_TYPE_RECORDER, ShellRecorderClass)) +#define SHELL_IS_RECORDER(object) (G_TYPE_CHECK_INSTANCE_TYPE ((object), SHELL_TYPE_RECORDER)) +#define SHELL_IS_RECORDER_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), SHELL_TYPE_RECORDER)) +#define SHELL_RECORDER_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), SHELL_TYPE_RECORDER, ShellRecorderClass)) + +GType shell_recorder_get_type (void) G_GNUC_CONST; + +ShellRecorder *shell_recorder_new (ClutterStage *stage); + +void shell_recorder_set_filename (ShellRecorder *recorder, + const char *filename); +void shell_recorder_set_pipeline (ShellRecorder *recorder, + const char *pipeline); +gboolean shell_recorder_record (ShellRecorder *recorder); +void shell_recorder_close (ShellRecorder *recorder); +void shell_recorder_pause (ShellRecorder *recorder); +gboolean shell_recorder_is_recording (ShellRecorder *recorder); + +G_END_DECLS + +#endif /* __SHELL_RECORDER_H__ */ diff --git a/src/test-recorder.c b/src/test-recorder.c new file mode 100644 index 000000000..d8da9c506 --- /dev/null +++ b/src/test-recorder.c @@ -0,0 +1,95 @@ +#include "shell-recorder.h" +#include +#include + +/* Very simple test of the ShellRecorder class; shows some text strings + * moving around and records it. + */ +static ShellRecorder *recorder; + +static gboolean +stop_recording_timeout (gpointer data) +{ + shell_recorder_close (recorder); + return FALSE; +} + +static void +on_animation_completed (ClutterAnimation *animation) +{ + g_timeout_add (1000, stop_recording_timeout, NULL); +} + +int main (int argc, char **argv) +{ + ClutterActor *stage; + ClutterActor *text; + ClutterAnimation *animation; + ClutterColor red, green, blue; + + g_thread_init (NULL); + gst_init (&argc, &argv); + clutter_init (&argc, &argv); + + clutter_color_from_string (&red, "red"); + clutter_color_from_string (&green, "green"); + clutter_color_from_string (&blue, "blue"); + stage = clutter_stage_get_default (); + + text = g_object_new (CLUTTER_TYPE_TEXT, + "text", "Red", + "font-name", "Sans 40px", + "color", &red, + NULL); + clutter_container_add_actor (CLUTTER_CONTAINER (stage), text); + animation = clutter_actor_animate (text, + CLUTTER_EASE_IN_OUT_QUAD, + 3000, + "x", 320, + "y", 240, + NULL); + g_signal_connect (animation, "completed", + G_CALLBACK (on_animation_completed), NULL); + + text = g_object_new (CLUTTER_TYPE_TEXT, + "text", "Blue", + "font-name", "Sans 40px", + "color", &blue, + "x", 640, + "y", 0, + NULL); + clutter_actor_set_anchor_point_from_gravity (text, CLUTTER_GRAVITY_NORTH_EAST); + clutter_container_add_actor (CLUTTER_CONTAINER (stage), text); + animation = clutter_actor_animate (text, + CLUTTER_EASE_IN_OUT_QUAD, + 3000, + "x", 320, + "y", 240, + NULL); + + text = g_object_new (CLUTTER_TYPE_TEXT, + "text", "Green", + "font-name", "Sans 40px", + "color", &green, + "x", 0, + "y", 480, + NULL); + clutter_actor_set_anchor_point_from_gravity (text, CLUTTER_GRAVITY_SOUTH_WEST); + clutter_container_add_actor (CLUTTER_CONTAINER (stage), text); + animation = clutter_actor_animate (text, + CLUTTER_EASE_IN_OUT_QUAD, + 3000, + "x", 320, + "y", 240, + NULL); + + recorder = shell_recorder_new (CLUTTER_STAGE (stage)); + shell_recorder_set_filename (recorder, "test-recorder.ogg"); + + clutter_actor_show (stage); + + shell_recorder_record (recorder); + clutter_main (); + + return 0; +} diff --git a/tools/build/gnome-shell-build-setup.sh b/tools/build/gnome-shell-build-setup.sh index ccabdf6a5..cf00c8fc8 100755 --- a/tools/build/gnome-shell-build-setup.sh +++ b/tools/build/gnome-shell-build-setup.sh @@ -71,6 +71,7 @@ if test x$system = xFedora ; then librsvg2-devel libwnck-devel mesa-libGL-devel python-devel readline-devel \ xulrunner-devel libXdamage-devel \ gdb glx-utils xorg-x11-apps xorg-x11-server-Xephyr xterm zenity \ + gstreamer-devel gstreamer-plugins-base gstreamer-plugins-good \ ; do if ! rpm -q $pkg > /dev/null 2>&1; then reqd="$pkg $reqd"