yaz/commit

first prototype for shot annotation based on user tags

authorMichiel Hildebrand
Fri Oct 28 18:21:46 2011 +0200
committerMichiel Hildebrand
Fri Oct 28 18:21:46 2011 +0200
commit78b53172f8a2cfab034c033b0111e1fde78a8cea
tree50acb053036e57d79b55fd525f2b3b1c96e2b304
parent5ee4fd918aa7f55bb5175b2359e3d6c9c1418a49
Diff style: patch stat
diff --git a/applications/yaz_game.pl b/applications/yaz_game.pl
index 700d8a7..92a37b9 100644
--- a/applications/yaz_game.pl
+++ b/applications/yaz_game.pl
@@ -42,7 +42,7 @@
 	   'Interval between requests to the server (in miliseconds)').
 :- setting(video_buffer_time, integer, 1,
 	   'Expect delay in load of page and start of Video (in seconds)').
-:- setting(gamestart, oneof([all,creator,none,false]), none,
+:- setting(gamestart, oneof([all,creator,none,false]), creator,
 	   'Determines who can start the game').
 :- setting(garden, oneof([player,sgarden,mgarden,cgarden,false]), mgarden,
 	   'Redirect to gardening when video is finished').
@@ -53,6 +53,15 @@
 :- rdf_meta
         cond_object_assert(r,r,o,r).
 
+channel(pprime1,
+	'https://ecrit10:pPRIME4ever@fileportal.rai.it/da%20RAI%20PRIME/LL/PrestoPRIME_ORF_197668_P0142620.webm',
+	'PPrime test 1',
+	600).
+channel(pprime2,
+	'https://ecrit10:pPRIME4ever@fileportal.rai.it/da%20RAI%20PRIME/LL/PrestoPRIME_UIBK_003383.ogv',
+	'PPrime test 2',
+	1000).
+
 channel('http://nos.nl/uitzending/32556-nos-journaal-1000-uur.html',
 	video('nos_journaal_10022011.flv'),
 	'NOS journaal 10 Feb 2011',
@@ -93,7 +102,7 @@ channel('http://g.bbcredux.com/programme/bbcone/2010-11-21/22-00-00',
 %       Emit a web page with a video tagging game.
 
 http_yaz_game(Request) :-
- 	ensure_logged_on(User0),
+	ensure_logged_on(User0),
 	user_property(User0, url(User)),
 	http_parameters(Request,
 			[  url(URL,
@@ -152,7 +161,7 @@ user_tags(Game, User, Tags) :-
 	pairs_values(Tags2, Tags).
 
 user_tag(Game, User, Annotation, Tag, Time, Match) :-
- 	rdf(Annotation, pprime:creator, User, Game),
+	rdf(Annotation, pprime:creator, User, Game),
 	rdf(Annotation, rdf:value, literal(Tag)),
 	rdf(Annotation, pprime:videoPlayhead, literal(Time), _),
 	(   matched_json_term(Annotation, Match)
@@ -161,7 +170,7 @@ user_tag(Game, User, Annotation, Tag, Time, Match) :-
 	).
 
 matched_json_term(Annotation, json([score=Score, multiplier=Multiplier])) :-
-  	rdf(Annotation, pprime:score, literal(Score)),
+	rdf(Annotation, pprime:score, literal(Score)),
 	rdf(Annotation, pprime:multiplier, literal(Multiplier)).
 
 
@@ -191,7 +200,7 @@ html_home_page(Channels) :-
 				    \html_new_game)
 			      ]),
 			  script(type('text/javascript'), [])
- 			]).
+			]).
 
 html_channels([]) --> !.
 html_channels([channel(URL, Video0, Title)|Vs]) -->
@@ -203,7 +212,7 @@ html_channels([channel(URL, Video0, Title)|Vs]) -->
 	      Players = []
 	  ),
 	  http_link_to_id(http_yaz_game, [url(URL)], Link)
-  	},
+	},
 	html(div(class(channel),
 		 [ h4(Title),
 		   div(class('thumb-container video'),
@@ -266,7 +275,7 @@ html_waiting_page(Game, URL, User, Players) :-
 			  div(id(options),
 			      [ \html_creator_options(Game, User)
 			      ]),
- 			 script(type('text/javascript'),
+			 script(type('text/javascript'),
 			       \html_waiting_yui(Game, Players, User))
 			]).
 
@@ -279,7 +288,7 @@ html_creator_options(Game, User) -->
 	{ setting(gamestart, creator),
 	  user_process_creator(Game, User),
 	  http_link_to_id(http_yaz_game, [game(Game)], Link)
- 	},
+	},
 	html(a(href(Link), 'start the game')).
 html_creator_options(_, _) --> !.
 
@@ -289,10 +298,10 @@ html_waiting_yui(Game, Players, User) -->
 	   http_absolute_location(js('game/players.js'), GamePlayers, []),
 	   setting(max_player_count, Max)
 	},
-  	js_yui3([{modules:{'game-players':{fullpath:GamePlayers}}}],
+	js_yui3([{modules:{'game-players':{fullpath:GamePlayers}}}],
 		[node,'base','io-base','json-parse','querystring-stringify-simple',
 		 'game-players'
- 		],
+		],
 		[ \js_function_decl(fetchData, [],
 				    \[
 '    Y.io("',DataServer,'", {data:{game:"',Game,'",user:"',User,'"},
@@ -310,8 +319,8 @@ html_waiting_yui(Game, Players, User) -->
 						  emptyShow:symbol(true)
 						 })),
 		  %\js_call(fetchData)
-   		  \js_call('Y.later'(500, symbol('Y'), symbol(fetchData), {}, symbol(true)))
-  		]).
+		  \js_call('Y.later'(500, symbol('Y'), symbol(fetchData), {}, symbol(true)))
+		]).
 
 
 %%	html_game_page(+Game, +URL, +User, +PlayHead, +Player, +Tags)
@@ -330,13 +339,13 @@ html_game_page(Game, URL, User, PlayHead, Players, Tags) :-
 			]).
 
 html_page_containers(Title) -->
- 	html([ h2(Title),
+	html([ h2(Title),
 	       div(id(main),
 		   [ div(id(videoplayer), []),
 		     div(class(input),
 			 [ input([type(text), id(taginput)]),
 			   div(id(suggest), []),
- 			   div([id(tags)], [])
+			   div([id(tags)], [])
 			 ])
 		   ]),
 	       div(id(players), [])
@@ -352,10 +361,10 @@ html_page_yui(Game, URL, User, PlayHead, Players, Tags) -->
 	  video_source(URL, Video)
 	},
 	html_requires(js('videoplayer/swfobject.js')),
- 	js_yui3([{modules:{'game-players':{fullpath:GamePlayers},
+	js_yui3([{modules:{'game-players':{fullpath:GamePlayers},
 			   'video-player':{fullpath:VideoPlayer},
 			   'game-input':{fullpath:GameInput}
- 			  }}
+			  }}
 		],
 		[node,event,widget,anim,
 		 'io-base','json-parse','querystring-stringify-simple',
@@ -363,7 +372,7 @@ html_page_yui(Game, URL, User, PlayHead, Players, Tags) -->
 		],
 		[ \js_new(videoPlayer,
 			  'Y.mazzle.VideoPlayer'({filepath:FilePath,
- 						  src:Video,
+						  src:Video,
 						  width:640,
 						  height:380,
 						  controls:symbol(false),
@@ -374,7 +383,7 @@ html_page_yui(Game, URL, User, PlayHead, Players, Tags) -->
 			  'Y.mazzle.GameInput'({input:'#taginput',
 						output:'#tags',
 						tags:Tags
- 					       })),
+					       })),
 		  \js_new(gamePlayers,
 			  'Y.mazzle.GamePlayers'({container:'#players',
 						  user:User,
@@ -386,7 +395,7 @@ html_page_yui(Game, URL, User, PlayHead, Players, Tags) -->
 		  \js_call('videoPlayer.render'('#videoplayer')),
 		  \js_yui3_on(gameInput, addTag, addTag),
 		  \js_yui3_on(videoPlayer, end, \js_video_end(Garden, Game))
-  		]).
+		]).
 
 js_support_functions(Game, User) -->
 	{ http_location_by_id(http_game_add_tag, AddTag),
@@ -446,7 +455,7 @@ create_game(URL, Player, Game, _Options) :-
 create_game(URL, Player, Game, Options) :-
 	option(video(Video), Options, _),
 	option(title(Title), Options, _),
-   	create_user_process(Player,
+	create_user_process(Player,
 			    [rdf:type=pprime:'Game',
 			     opmv:used=URL
 			    ], Game),
@@ -487,7 +496,7 @@ game_video_start(_Game, '', 0). % for testing
 %	Asserts that Player has joined game.
 
 join_game(Game, Player) :-
- 	(   user_process_joined(Game, Player)
+	(   user_process_joined(Game, Player)
 	->  set_active_process(Game)
 	;   join_user_process(Game, Player),
 	    debug(game, 'Game ~w joined by ~w', [Game, Player]),
@@ -501,12 +510,12 @@ join_game(Game, Player) :-
 
 add_tag(Game, Player, Tag, PlayHead, AnnotationId) :-
 	rdf(Game, opmv:used, URL),
- 	create_video_annotation(URL, literal(Tag), PlayHead, Player, AnnotationId),
+	create_video_annotation(URL, literal(Tag), PlayHead, Player, AnnotationId),
 	debug(game, 'Added tag ~w at time ~w by ~w (~w)', [Tag, PlayHead, Player, AnnotationId]).
 
 
 		 /*******************************
-		 *	    data services      	*
+		 *	    data services	*
 		 *******************************/
 
 :- http_handler(yaz('waitingdata'), http_waiting_data, []).
@@ -519,10 +528,10 @@ add_tag(Game, Player, Tag, PlayHead, AnnotationId) :-
 %	Return JSON object with waiting players in game.
 
 http_waiting_data(Request) :-
- 	http_parameters(Request,
+	http_parameters(Request,
 			[  game(Game,
 				[uri, description('URL of current Game')])
- 			]),
+			]),
 	(   rdf(Game, opmv:wasStartedAt, _)
 	->  reply_json(json([start= @true]))
 	;   PlayerObj = json([player=P, name=N]),
@@ -541,7 +550,7 @@ http_waiting_data(Request) :-
 %	Return JSON object with players in a game.
 
 http_game_add_tag(Request) :-
- 	http_parameters(Request,
+	http_parameters(Request,
 			[  game(Game,
 				[uri, description('URL of current Game')]),
 			   user(User,
@@ -552,11 +561,11 @@ http_game_add_tag(Request) :-
 			       [description('Optionally a new tag can be added')])
 			]),
 	setting(match_interval, Interval),
- 	add_tag(Game, User, Tag, Playhead, Id),
+	add_tag(Game, User, Tag, Playhead, Id),
 	matching_tags(Game, User, Id, Tag, Playhead, Interval, Matches),
 	length(Matches, C),
 	rdf_transaction(update_scores(Matches, 0, C, Game)),
- 	reply_json(json([id=Id])).
+	reply_json(json([id=Id])).
 
 
 %%	match_existing_tags(+Game, +User, +Tag, +Playhead, +Interval,
@@ -578,7 +587,7 @@ matching_tags(Game, User, Id, Tag, Playhead, Interval, Matches) :-
 matching_tag(Game, User, Tag, Playhead, Interval, Id, Player, Match, Time) :-
 	Start is Playhead-Interval,
 	tag_match(Tag, Game, Id, Match),
-  	rdf(Id, pprime:videoPlayhead, literal(between(Start,Playhead), Time)),
+	rdf(Id, pprime:videoPlayhead, literal(between(Start,Playhead), Time)),
 	rdf(Id, pprime:creator, Player),
 	User \== Player.
 
@@ -594,13 +603,13 @@ update_scores([], _, _, _).
 update_scores([match(Id,Player,Match)|Ms], N, Count, Game) :-
 	N1 is N + 1,
 	match_score(Match, N1, Count, Points, Multiplier),
-  	update_tag_score(Id, Game, Points, Multiplier),
+	update_tag_score(Id, Game, Points, Multiplier),
 	update_player_score(Game, Player, Points),
 	update_scores(Ms, N1, Count, Game).
 
 update_player_score(Game, Player, Points) :-
 	player_score(Game, Player, OldScore),
- 	retractall(player_score(Game, Player, OldScore)),
+	retractall(player_score(Game, Player, OldScore)),
 	NewScore is OldScore+Points,
 	assert(player_score(Game, Player, NewScore)).
 
@@ -615,9 +624,9 @@ order_count_multiplier(Order, Count, X) :-
 	X is 4/(Order*2) + ((Count-2)/2).
 
 update_tag_score(Annotation, Game, Score, Multiplier) :-
- 	rdf_retractall(Annotation, pprime:score, _),
+	rdf_retractall(Annotation, pprime:score, _),
 	rdf_retractall(Annotation, pprime:multiplier, _),
- 	rdf_assert(Annotation, pprime:score, literal(Score), Game),
+	rdf_assert(Annotation, pprime:score, literal(Score), Game),
 	rdf_assert(Annotation, pprime:multiplier, literal(Multiplier), Game).
 
 
@@ -626,14 +635,14 @@ update_tag_score(Annotation, Game, Score, Multiplier) :-
 %	Return JSON object with players in a game.
 
 http_game_data(Request) :-
- 	http_parameters(Request,
+	http_parameters(Request,
 			[  game(Game,
 				[uri, description('URL of current Game')]),
 			   user(User,
 				[uri, description('URL of current User')]),
 			   playhead(Playhead,
 				[number, description('Current time of the video play head (in miliseconds)')])
- 			]),
+			]),
 	setting(match_interval, Interval),
 	PlayerObj = json([player=P, name=Name, score=Score]),
 	findall(PlayerObj,
@@ -652,9 +661,9 @@ http_game_data(Request) :-
 
 user_matched_tag(Game, User, Playhead, Interval, Id, Tag, Time, Match) :-
 	Start is Playhead-Interval,
-  	rdf(Id, pprime:videoPlayhead, literal(between(Start,Playhead), Time), Game),
+	rdf(Id, pprime:videoPlayhead, literal(between(Start,Playhead), Time), Game),
 	rdf(Id, pprime:creator, User),
- 	matched_json_term(Id, Match),
+	matched_json_term(Id, Match),
 	rdf(Id, rdf:value, literal(Tag)).
 
 
@@ -671,7 +680,7 @@ assert_channel_info :-
 	rdf_transaction((   channel(URL, Video0, Title, Duration),
 			    http_absolute_location(Video0, Video, []),
 			    add_video(URL, Video, Title, Duration),
- 			    fail
+			    fail
 			;   true
 			)).
 
@@ -681,7 +690,8 @@ assert_channel_info :-
 
 add_video(URL, Video, Title, Duration) :-
 	%rdf_retractall(URL, _, _),
- 	cond_object_assert(URL, pprime:source, Video, video),
+	cond_object_assert(URL, rdf:type, pprime:'Video', video),
+	cond_object_assert(URL, pprime:source, Video, video),
 	cond_object_assert(URL, dc:title, literal(Title), video),
 	cond_object_assert(URL, pprime:duration, literal(Duration), video).
 
diff --git a/applications/yaz_shot_annotation.pl b/applications/yaz_shot_annotation.pl
new file mode 100644
index 0000000..6aba072
--- /dev/null
+++ b/applications/yaz_shot_annotation.pl
@@ -0,0 +1,225 @@
+:- module(yaz_shot_annotation,
+	  []).
+
+:- use_module(library(http/http_dispatch)).
+:- use_module(library(http/http_parameters)).
+:- use_module(library(http/http_path)).
+:- use_module(library(http/html_write)).
+:- use_module(library(http/html_head)).
+:- use_module(library(http/http_json)).
+:- use_module(library(http/js_write)).
+:- use_module(library(http/json)).
+:- use_module(library(http/http_session)).
+:- use_module(user(user_db)).
+:- use_module(library(semweb/rdf_db)).
+:- use_module(library(semweb/rdfs)).
+:- use_module(library(semweb/rdf_label)).
+
+:- use_module(library(yaz_util)).
+:- use_module(library(yui3)).
+:- use_module(library(video_annotation)).
+
+:- use_module(applications(yaz_tag)).
+:- use_module(components(label)).
+:- use_module(components(yaz_page)).
+:- use_module(components(yaz_video_item)).
+
+:- use_module(library(rdf_history)).
+:- use_module(library(user_process)).
+
+:- http_handler(yaz(shot), http_yaz_shot, []).
+
+
+
+reconcile_source(gtaa,
+		 'Person',
+		 Server,
+		 Params) :-
+	http_location_by_id(http_reconcile, Server),
+	www_form_encode('[{"http://www.w3.org/2004/02/skos/core#inScheme":"http://data.beeldengeluid.nl/gtaa/GTAA"}]',Ps),
+	atom_concat('&properties=',Ps,Params).
+reconcile_source(cornetto,
+		 'Subject',
+		 Server,
+		 '&type=http://purl.org/vocabularies/cornetto/Synset') :-
+	http_location_by_id(http_reconcile, Server).
+reconcile_source(geonames,
+		 'Location',
+'http://api.kasabi.com/api/reconciliation-api-geonames',
+		'&apikey=908177a484aa25f9b602d3eb76cf057d73e7aa39').
+%reconcile_source(dbpedia,
+%		 'DBPedia',
+%'http://api.kasabi.com/api/reconciliation-api-dbpedia-36',
+%		 '&apikey=908177a484aa25f9b602d3eb76cf057d73e7aa39').
+%reconcile_source(freebase,
+%		 'Freebase',
+%		 'http://standard-reconcile.freebaseapps.com/reconcile',
+%		 '').
+
+
+
+%%	http_yaz_shot(+Request)
+%
+%	Emit an HTML page to link tags to concepts.
+
+http_yaz_shot(Request) :-
+	http_parameters(Request,
+			[ video(Video,
+				[description('Current video')])
+			]),
+	video_annotations(Video, As0, []),
+	sort_by_arg(As0, 2, Annotations),
+	findall(Id=json([label=Label,url=URL,parameters=Parameters]),
+		reconcile_source(Id,Label,URL,Parameters), Sources),
+	html_page(Video, Annotations, json(Sources)).
+
+
+%%	html_page(+Video, +Annotations)
+%
+%	Emit an HTML page for concept gardening
+
+html_page(Video, Annotations, Sources) :-
+	reply_html_page(yaz,
+			[ title(['YAZ - ', Video])
+			],
+			[ \html_requires(yui3('cssgrids/grids-min.css')),
+			  \html_requires(css('shotgarden.css')),
+			  \yaz_video_header(Video),
+			  div(class('yui3-g'),
+			      [ div([class('yui3-u'), id(nav)],
+				    [ div(class(hd), '1. Select shot'),
+				      div(id(videoframes), [])
+				    ]),
+				div([class('yui3-u'), id(main)],
+				    [ div(class(hd), '2. View content'),
+				      div(id(timeline), []),
+				      div(id(videoplayer), [])
+				    ]),
+				div([class('yui3-u'), id('select-tag')],
+				    [ div(class(hd), '3. Select relevant tags'),
+				      div(id(taglist), [])
+				    ]),
+				div([class('yui3-u'), id('select-concept')],
+				    [ div(class(hd), '4. Select concept'),
+				      div(id(taglinker), [])
+				    ])
+			      ]),
+			  script(type('text/javascript'),
+				\html_video_page_yui(Video, Annotations, Sources))
+			]).
+
+html_annotation_form -->
+	html(table([ tr([td(class(label), 'Who'),
+			 td(input([type(text)]))
+			]),
+		     tr([td(class(label), 'Where'),
+			 td(input([type(text)]))
+			]),
+		     tr([td(class(label), 'When'),
+			 td(input([type(text)]))
+			])
+		   ])).
+
+
+html_video_page_yui(Video, Annotations, Sources) -->
+	{ video_source(Video, Src, Duration),
+	  http_location_by_id(serve_video_frame, FrameServer),
+	  http_absolute_location(js('videoplayer/'), FilePath, []),
+	  http_absolute_location(js('videoplayer/videoplayer.js'), Videoplayer, []),
+	  http_absolute_location(js('timeline/timeline.js'), Timeline, []),
+	  http_absolute_location(js('videoframes/videoframes.js'), Videoframes, []),
+	  http_absolute_location(js('tagplayer/taglist.js'), Taglist, []),
+	  http_absolute_location(js('tagplayer/tagLinker.js'), TagLinker, []),
+	  annotation_to_json(Annotations, JSONTags),
+	  Frames = [{startTime:2000},{startTime:10000},{startTime:20000},
+		    {startTime:30000},{startTime:40000},{startTime:50000}]
+	},
+	html_requires(js('videoplayer/swfobject.js')),
+	js_yui3([{modules:{'video-player':{fullpath:Videoplayer},
+			   'timeline':{fullpath:Timeline},
+			   'video-frames':{fullpath:Videoframes},
+			   'tag-list':{fullpath:Taglist},
+			   'tag-linker':{fullpath:TagLinker}
+			  }}
+		],
+		[node,event,widget,anim,
+		 'json','jsonp','querystring-stringify-simple',io,
+		 'video-player','video-frames',timeline,
+		 'tag-list','tag-linker'
+		],
+		[ \js_new(videoPlayer,
+			  'Y.mazzle.VideoPlayer'({filepath:FilePath,
+						  src:Src,
+						  width:540,
+						  height:400,
+						  autoplay:symbol(false),
+						  controls:symbol(true),
+						  duration:Duration
+						 })),
+		  \js_new(timeline,
+			  'Y.mazzle.Timeline'({height:20,
+					       width:540,
+					       duration:Duration,
+					       items:JSONTags
+					      })),
+		  \js_new(videoFrames,
+			 'Y.mazzle.VideoFrames'({frameServer:FrameServer,
+						 video:Src,
+						 width:200,
+						 height:600,
+						 frames:Frames,
+						 interval:0,
+						 duration:Duration,
+						 playerPath:FilePath,
+						 confirm:symbol(false),
+						 showRelated:symbol(false),
+						 showTime:symbol(true)
+						})),
+		   \js_new(tagList,
+			 'Y.mazzle.TagList'({tags:JSONTags,
+					     height:425,
+					     width:220
+					    })),
+		  \js_new(tagLinker,
+			 'Y.mazzle.TagLinker'({sources:Sources,
+					       width:220
+					    })),
+		  \js_yui3_render(videoPlayer, #(videoplayer)),
+		  \js_yui3_render(timeline, #(timeline)),
+		  \js_yui3_render(videoFrames, #(videoframes)),
+		  \js_yui3_render(tagList, #(taglist)),
+		  \js_yui3_render(tagLinker, #(taglinker)),
+		  \js_yui3_on(videoFrames, frameSelect, \js_frame_select),
+		  \js_yui3_on(tagList, itemSelect, \js_tag_select),
+		  \js_yui3_on(tagLinker, submit, \js_concept_select),
+		  \js_yui3_on(tagLinker, cancel, \js_concept_cancel)
+		]).
+
+js_frame_select -->
+	js_function([e],
+		    \[
+'    var frame = e.frame;
+     var time = (frame.startTime/1000);
+     videoPlayer.setTime(time, true);\n'
+		     ]).
+
+js_tag_select -->
+	js_function([e],
+		    \[
+'   Y.one("#select-tag").addClass("hidden");
+    Y.one("#select-concept").removeClass("hidden");
+    tagLinker.set("tag", e.tag.tag.value);\n'
+		     ]).
+js_concept_cancel -->
+	js_function([e],
+		    \[
+'   Y.one("#select-concept").addClass("hidden");
+    Y.one("#select-tag").removeClass("hidden");\n'
+		     ]).
+js_concept_select -->
+	js_function([e],
+		    \[
+'   console.log(e.concept);
+    Y.one("#select-concept").addClass("hidden");
+    Y.one("#select-tag").removeClass("hidden");\n'
+		     ]).
diff --git a/lib/yaz_util.pl b/lib/yaz_util.pl
index 14862f3..2a2fccc 100644
--- a/lib/yaz_util.pl
+++ b/lib/yaz_util.pl
@@ -323,7 +323,10 @@ delete_nonground([_H|T], Rest) :-
 video_source(URL, Video, Duration) :-
 	video_source(URL, Video),
 	(   rdf(URL, pprime:duration, literal(Duration0))
-	->  atom_number(Duration0, Duration1),
+	->  (   number(Duration0)
+	    ->	Duration1 = Duration0
+	    ;	atom_number(Duration0, Duration1)
+	    ),
 	    Duration is Duration1*1000
 	;   Duration = 0
 	).
diff --git a/web/css/shotgarden.css b/web/css/shotgarden.css
new file mode 100644
index 0000000..4a87078
--- /dev/null
+++ b/web/css/shotgarden.css
@@ -0,0 +1,179 @@
+#body {
+	margin: 0 auto;
+}
+
+/* header and description */
+.video-results h2 {
+	margin-bottom: 0;
+}
+.video-results .desc {
+	margin-bottom: 15px;
+	max-height: 2em;
+	color: #888;
+	font-size: 95%;
+	clear: both;
+}
+
+/* general page elements */
+.hd {
+	padding: 4px;
+	font-weight: bold;
+	border-width: 1px 1px 0 1px;
+	border-style: solid;
+	border-color: #CCC;
+	background-color: #EEE;
+}
+
+
+
+/* page layout */
+
+#nav {
+	width: 200px;
+}
+#main {
+	width: 540px;
+	margin: 0 10px;
+}
+#select-tag {
+	width: 220px;
+}
+#select-concept {
+	width: 220px;
+}
+#select-tag.hidden,
+#select-concept.hidden {
+	display:none;
+}
+
+
+
+/* video frames */
+.yui3-video-frames {
+}
+.yui3-video-frames-content {
+	overflow: auto;
+	border: 1px solid #CCC;
+} 
+.yui3-video-frames .frames-list {
+}
+.yui3-video-frames-disabled {
+	display: none;
+}
+.yui3-video-frames ul.frames-list {
+	margin: 0;
+	padding: 0;
+}
+.yui3-video-frames li {
+	overflow: hidden;
+	list-style: none;
+	border: 4px solid transparent;
+}
+.yui3-video-frames li.selected {
+	border-color: yellow;
+}
+.yui3-video-frames li.hidden {
+	display: none;
+}
+.yui3-video-frames img {
+	width: 190px;
+}
+.yui3-video-frames .image {
+	overflow: hidden;
+}
+.yui3-video-frames .tag {
+	text-align: center;
+	padding: 3px 0;
+	background-color: #DDD;
+	cursor: pointer;
+}
+
+
+/* timeline */
+.yui3-timeline {
+	background-color:#CCCCCC;
+	height:15px;
+	width:100%;
+}
+.yui3-timeline ul {
+	margin: 0;
+	padding: 0;
+}
+.yui3-timeline li {
+	list-style: none;
+	position: absolute;
+	margin: 1px 0;
+	height: 18px;
+	background-color:yellow;
+}
+.yui3-timeline li.hidden {
+	display: none;
+}
+.yui3-timeline li.highlight {
+	background-color:red;
+}
+
+
+/* tag player */
+.yui3-tag-list {
+	background: transparent;
+	overflow: auto;
+	border: 1px solid #CCCCCC;
+}
+.yui3-tag-list ul {
+	margin: 0;
+	padding: 0;
+}
+.yui3-tag-list li {
+	overflow: hidden;
+	list-style: none;
+	margin: 1px 0;
+	padding: 4px 8px;
+}
+.yui3-tag-list li:nth-child(even) {
+	background-color: #EEE;
+}
+.yui3-tag-list li.focus .label {
+    font-size: 150%;
+}
+.yui3-tag-list li .label {
+	cursor: pointer;
+}
+.yui3-tag-list li .label.concept {
+	color: #0033CC;
+}
+
+
+/* tag linker */
+
+.yui3-tag-linker {
+	background: transparent;
+}
+.yui3-tag-linker-content {
+	overflow: auto;
+	height: 398px;
+	border: 1px solid #CCCCCC;
+}
+.yui3-tag-linker .sources {
+	height: 370px;
+}	
+.yui3-tag-linker .controls {
+	height: 25px;
+}
+.yui3-tag-linker .controls button {
+	width: 50%;
+	height: 25px;
+	border: none;
+}
+.yui3-tag-linker ul {
+	margin: 0;
+	padding: 0;
+}
+.yui3-tag-linker li {
+	list-style: none;
+	margin: 1px 0;
+	padding: 4px 8px;
+}
+.yui3-tag-linker li.hidden {
+	display: none;
+}
\ No newline at end of file
diff --git a/web/js/tagplayer/tagLinker.js b/web/js/tagplayer/tagLinker.js
index 3e726a4..3511bb9 100644
--- a/web/js/tagplayer/tagLinker.js
+++ b/web/js/tagplayer/tagLinker.js
@@ -25,15 +25,17 @@ YUI.add('tag-linker', function(Y) {
 	 * as with any other class extending Base.
 	 */
 	TagLinker.ATTRS = {
-		items: {
-			value: []
+		tag: {
+			value: null
+		},
+		sources: {
+			value: {}
+		},
+		limit: {
+			value: 3
 		}
 	};
 
-	/* Static constants used to define the markup templates used to create TagLinker DOM elements */
-	TagLinker.LIST_CLASS = 'tag-list';
-	TagLinker.LIST_TEMPLATE = '<ul class="'+TagLinker.LIST_CLASS+'"></ul>';
-
 	/* TagLinker extends the base Widget class */
 	Y.extend(TagLinker, Widget, {
 
@@ -45,56 +47,112 @@ YUI.add('tag-linker', function(Y) {
 
 		renderUI : function() {
 			var content = this.get("contentBox");
-			this.listNode = content.appendChild(Node.create(TagLinker.LIST_TEMPLATE));
-			var controls = content.appendChild('<div class="controls hidden"></div>');
-			this.applyButton = controls.appendChild('<button>apply</button>');
-			this.applyAllButton = controls.appendChild('<button>apply to all</button>');
-			this.controls = controls;
+
+			var tagNode = content.appendChild(Node.create('<div class="tag"></div>'));
+			var linkBox = content.appendChild(Node.create('<div class="sources"></div>'));
+			var controls = content.appendChild('<div class="controls"></div>');
+			this.cancelButton = controls.appendChild('<button>cancel</button>');
+			this.submitButton = controls.appendChild('<button>submit</button>');
+			this._renderSources(linkBox);
+			this.tagNode = tagNode;
 		},
 
 		bindUI : function() {
-			this.after("itemsChange", this.syncUI);
-			this.applyButton.on("click", this._applySelect, this, {"applyToAll":false});
-			this.applyAllButton.on("click", this._applySelect, this, {"applyToAll":true});
+			this.after("tagChange", this.syncUI);
+			this.cancelButton.on("click", this._cancel, this);
+			this.submitButton.on("click", this._submit, this);
 		},
 
 		syncUI : function() {
-			this._renderItems();
+			var tag = this.get("tag");
+			if(tag) {
+				this.tagNode.setContent(tag);
+				this._fetchConcepts();
+			} else {
+				this.tagNode.setContent("");
+				// hide source results
+			}
 		},
 		
-		_applySelect : function(e, args) {
-			var items = this.get("items"),
-				checked = this.listNode.one("li input:checked");
-			if(checked) {
-				var index = this.listNode.all("li").indexOf(checked.get("parentNode"));
-				this.fire("applySelect", {
-					item:items[index],
-					applyToAll:args.applyToAll
-				})
-			}
+		_cancel: function() {
+			var tag = this.get("tag");
+			this.fire("cancel", {"tag":tag});
+		},
+		
+		_submit: function() {
+			var tag = this.get("tag"),
+				selected = this.get("contentBox").one("input[name=reconcileItem]:checked"),
+				concept = selected.get("value");
 			
+			if(concept) {
+				this.fire("submit", {"tag":tag, "concept":concept});
+			}	
+
 		},
 		
-		_renderItems : function() {
-			var items = this.get("items"),
-				list = this.listNode;
-			list.setContent("");
-			if(items.length>0) {
-				for (var i=0; i < items.length; i++) {
-		  			list.append("<li>"+this.formatItem(items[i])+"</li>");
-				}
-				this.controls.removeClass("hidden");
-			} else {
-				this.controls.addClass("hidden");
+		_renderSources : function(node) {
+			var sources = this.get("sources");
+			for(var key in sources) {
+				var source = sources[key],
+					label = source.label,
+					img = source.img;
+				
+				var box = node.appendChild(Node.create('<div class="source"></div>'));
+				box.appendChild('<div class="source-hd"><img src="'+img+'" alt="'+label+'"></div>');
+				var bd = box.appendChild(Node.create('<div class="source-bd"></div>'));
+				source._list = bd.appendChild(this._renderSourceList());
 			}
 		},
 		
+		_renderSourceList : function() {
+			var limit = this.get("limit");
+			var listNode = Node.create('<ul></ul>');
+			for (var i=0; i < limit; i++) {
+				listNode.appendChild("<li class='hidden'></li>");
+			};
+			return listNode;
+		},
+		
+		_fetchConcepts : function() {
+			var tag = this.get("tag"),
+				sources = this.get("sources"),
+				limit = this.get("limit");
+				
+			for(var key in sources) {
+				var source = sources[key],
+				url = source.url
+					+ "?callback={callback}"
+					+ source.parameters
+					+ "&query="+tag
+					+ "&limit="+limit;
+				Y.log('reconcile against '+source.label);
+				this.fire("reconcileStart", {"source":source});
+				this.reconcile(url, source);
+			}
+		},
+
+		reconcile : function(request, source) {
+			var oSelf = this,
+				items = source._list.all("li");
+ 			Y.jsonp(request, function(response) {
+				items.each(function(node, i) {
+					var results = response.result;
+					if(results[i]) {
+						node.setContent(oSelf.formatItem(results[i]));
+						node.removeClass("hidden");
+					} else {
+						node.addClass("hidden");
+					}
+				})
+			})
+		},
+		
 		formatItem : function(item) {
 			var id = item.id,
 				name = item.name,
 				types = item.type||[];
 				
-			var html = "<input type=radio name='reconcileItem'>";
+			var html = "<input type=radio name='reconcileItem' value='"+id+"'>";
 			html += "<span class='name'>"+name+"</span>";
 			html += "<div class='types'>";
 			for (var i=0; i < types.length; i++) {
diff --git a/web/js/tagplayer/taglist.js b/web/js/tagplayer/taglist.js
index a89dba8..92f812f 100644
--- a/web/js/tagplayer/taglist.js
+++ b/web/js/tagplayer/taglist.js
@@ -49,7 +49,7 @@ YUI.add('tag-list', function(Y) {
 		renderUI : function() {
 			var content = this.get("contentBox"),
 				height = this.get("height");
-						
+
 			// tag list
 			content.setStyle("position", "relative");
 			if(this.get("topIndent")) {
diff --git a/web/js/videoframes/videoframes.js b/web/js/videoframes/videoframes.js
index b4269e7..e14f1e2 100644
--- a/web/js/videoframes/videoframes.js
+++ b/web/js/videoframes/videoframes.js
@@ -138,7 +138,7 @@ YUI.add('video-frames', function(Y) {
 				if(frames[i]) {
 					var startTime = frames[i].startTime;
 					node.setContent(this.formatFrame(frames[i]));
-					if(startTime>time+interval) {
+					if(time==0 || startTime>time+interval) {
 						node.removeClass("hidden");
 					}
 					time = startTime;
@@ -153,7 +153,7 @@ YUI.add('video-frames', function(Y) {
 			var frameServer = this.get("frameServer"),
 				video = this.get("video"),
 				time = frame.startTime/1000,
-				label = frame.tag.label,
+				label = frame.tag ? frame.tag.label : '',
 				role = frame.role ? frame.role : '';
  
 			var html = '<div class="image">'