Rewrite search flow and enrich preview assets
build-push / docker (push) Successful in 4m6s

This commit is contained in:
AI Assistant
2026-03-13 12:50:25 +09:00
parent de2488654a
commit b78865d4bf
5 changed files with 432 additions and 268 deletions
+23 -14
View File
@@ -280,7 +280,11 @@ func (a *App) searchMedia(c *gin.Context) {
} }
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "ranking thumbnail candidates", "progress": 55}) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "ranking thumbnail candidates", "progress": 55})
scored := rankSearchResults(req.Query, results) rankQuery := req.Query
if len(queryVariants) > 0 {
rankQuery = strings.Join(queryVariants[:min(len(queryVariants), 3)], " ")
}
scored := rankSearchResults(rankQuery, results)
shortlist := scored[:min(len(scored), 10)] shortlist := scored[:min(len(scored), 10)]
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing shortlisted thumbnails with Gemini Vision", "progress": 75}) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing shortlisted thumbnails with Gemini Vision", "progress": 75})
recommended, err := a.GeminiService.Recommend(req.Query, shortlist) recommended, err := a.GeminiService.Recommend(req.Query, shortlist)
@@ -288,12 +292,13 @@ func (a *App) searchMedia(c *gin.Context) {
fallback := make([]services.AIRecommendation, 0, min(20, len(scored))) fallback := make([]services.AIRecommendation, 0, min(20, len(scored)))
for _, result := range scored[:min(20, len(scored))] { for _, result := range scored[:min(20, len(scored))] {
fallback = append(fallback, services.AIRecommendation{ fallback = append(fallback, services.AIRecommendation{
Title: result.Title, Title: result.Title,
Link: result.Link, Link: result.Link,
ThumbnailURL: result.ThumbnailURL, ThumbnailURL: result.ThumbnailURL,
Source: result.Source, PreviewVideoURL: result.PreviewVideoURL,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.", Source: result.Source,
Recommended: true, Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
}) })
} }
warning := err.Error() warning := err.Error()
@@ -379,9 +384,12 @@ func rankSearchResults(query string, results []services.SearchResult) []services
if result.ThumbnailURL != "" { if result.ThumbnailURL != "" {
score += 2 score += 2
} }
if result.PreviewVideoURL != "" {
score += 3
}
switch result.Source { switch result.Source {
case "Google Video": case "Google Video":
score += 1 score -= 1
case "Envato": case "Envato":
score += 7 score += 7
case "Artgrid": case "Artgrid":
@@ -419,12 +427,13 @@ func mergeRecommendations(recommended []services.AIRecommendation, ranked []serv
} }
seen[item.Link] = true seen[item.Link] = true
merged = append(merged, services.AIRecommendation{ merged = append(merged, services.AIRecommendation{
Title: item.Title, Title: item.Title,
Link: item.Link, Link: item.Link,
ThumbnailURL: item.ThumbnailURL, ThumbnailURL: item.ThumbnailURL,
Source: item.Source, PreviewVideoURL: item.PreviewVideoURL,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.", Source: item.Source,
Recommended: true, Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
}) })
} }
return merged return merged
+322 -90
View File
@@ -3,21 +3,24 @@ package services
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
) )
type SearchResult struct { type SearchResult struct {
Title string `json:"title"` Title string `json:"title"`
Link string `json:"link"` Link string `json:"link"`
DisplayLink string `json:"displayLink"` DisplayLink string `json:"displayLink"`
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
ThumbnailURL string `json:"thumbnailUrl"` ThumbnailURL string `json:"thumbnailUrl"`
Source string `json:"source"` PreviewVideoURL string `json:"previewVideoUrl"`
Source string `json:"source"`
} }
type SearchService struct { type SearchService struct {
@@ -47,84 +50,169 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
return nil, fmt.Errorf("searxng base url is not configured") return nil, fmt.Errorf("searxng base url is not configured")
} }
sources := []struct { type sourceConfig struct {
name string name string
categories string categories string
engine string engine string
queryBuilder func(string) string build func(string) []string
match func(SearchResult) bool accept func(SearchResult) bool
}{ }
sources := []sourceConfig{
{
name: "Envato",
categories: "general",
engine: s.WebEngine,
build: buildEnvatoQueries,
accept: isRenderableEnvatoResult,
},
{
name: "Artgrid",
categories: "general",
engine: s.WebEngine,
build: buildArtgridQueries,
accept: isRenderableArtgridResult,
},
{ {
name: "Google Video", name: "Google Video",
categories: "videos", categories: "videos",
engine: s.GoogleVideoEngine, engine: s.GoogleVideoEngine,
queryBuilder: func(query string) string { build: buildGoogleVideoQueries,
return buildGoogleVideoQuery(query) accept: isUsefulGoogleVideoResult,
},
match: isUsefulGoogleVideoResult,
},
{
name: "Envato",
categories: "general",
engine: s.WebEngine,
queryBuilder: buildEnvatoQuery,
match: isRenderableEnvatoResult,
},
{
name: "Artgrid",
categories: "general",
engine: s.WebEngine,
queryBuilder: buildArtgridQuery,
match: isRenderableArtgridResult,
}, },
} }
seen := map[string]bool{} seen := map[string]bool{}
results := make([]SearchResult, 0, 60) results := make([]SearchResult, 0, 90)
var lastErr error var lastErr error
for _, query := range queries {
query = strings.TrimSpace(query) baseQueries := limitQueries(queries, 5)
if query == "" { for _, base := range baseQueries {
base = strings.TrimSpace(base)
if base == "" {
continue continue
} }
for _, source := range sources { for _, source := range sources {
searchQuery := source.queryBuilder(query) for _, searchQuery := range source.build(base) {
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
items, err := s.search(searchQuery, source.categories, source.engine, source.name) if err != nil {
if err != nil { lastErr = err
lastErr = err items, err = s.search(searchQuery, source.categories, "", source.name)
items, err = s.search(searchQuery, source.categories, "", source.name)
}
if err != nil {
lastErr = err
if source.categories != "general" {
items, err = s.search(searchQuery, "general", "", source.name)
} }
} if err != nil {
if err != nil { lastErr = err
lastErr = err
continue
}
for _, item := range items {
if item.Link == "" || seen[item.Link] {
continue continue
} }
if !source.match(item) { for _, item := range items {
continue if item.Link == "" || seen[item.Link] || !source.accept(item) {
continue
}
seen[item.Link] = true
results = append(results, item)
} }
seen[item.Link] = true
results = append(results, item)
} }
} }
} }
if len(results) == 0 && lastErr != nil {
return nil, lastErr
}
sort.SliceStable(results, func(i, j int) bool { sort.SliceStable(results, func(i, j int) bool {
return sourceWeight(results[i].Source) > sourceWeight(results[j].Source) return sourceWeight(results[i].Source) > sourceWeight(results[j].Source)
}) })
if len(results) == 0 && lastErr != nil { return s.EnrichResults(results), nil
return nil, lastErr }
func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult {
limit := minInt(len(results), 24)
if limit == 0 {
return results
} }
return results, nil
enriched := make([]SearchResult, len(results))
copy(enriched, results)
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for idx := 0; idx < limit; idx++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
enriched[i] = s.enrichResult(enriched[i])
}(idx)
}
wg.Wait()
return enriched
}
func (s *SearchService) enrichResult(result SearchResult) SearchResult {
switch result.Source {
case "Envato":
return s.enrichEnvato(result)
case "Artgrid":
return s.enrichArtgrid(result)
default:
if result.ThumbnailURL == "" {
result.ThumbnailURL = deriveThumbnail(result.Link)
}
return result
}
}
func (s *SearchService) enrichEnvato(result SearchResult) SearchResult {
html, err := s.fetchText(result.Link)
if err != nil {
return result
}
if result.ThumbnailURL == "" {
result.ThumbnailURL = firstNonEmpty(
extractMetaContent(html, "og:image"),
extractMetaContent(html, "twitter:image"),
)
}
if result.PreviewVideoURL == "" {
result.PreviewVideoURL = extractVideoPreviewURL(html)
}
return result
}
func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult {
clipID := extractArtgridClipID(result.Link)
if clipID == "" {
return result
}
apiURL := "https://artgrid.io/api/clip/details?clipId=" + clipID
body, err := s.fetchJSONText(apiURL)
if err == nil {
urls := collectURLs(body)
if result.ThumbnailURL == "" {
result.ThumbnailURL = pickImageURL(urls)
}
if result.PreviewVideoURL == "" {
result.PreviewVideoURL = pickVideoURL(urls)
}
}
if result.ThumbnailURL == "" || result.PreviewVideoURL == "" {
html, err := s.fetchText(result.Link)
if err == nil {
if result.ThumbnailURL == "" {
result.ThumbnailURL = firstNonEmpty(
extractMetaContent(html, "og:image"),
extractMetaContent(html, "twitter:image"),
)
}
if result.PreviewVideoURL == "" {
result.PreviewVideoURL = extractVideoPreviewURL(html)
}
}
}
return result
} }
func (s *SearchService) search(query, categories, engine, source string) ([]SearchResult, error) { func (s *SearchService) search(query, categories, engine, source string) ([]SearchResult, error) {
@@ -170,29 +258,40 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
results := make([]SearchResult, 0, len(payload.Results)) results := make([]SearchResult, 0, len(payload.Results))
for _, item := range payload.Results { for _, item := range payload.Results {
link := strings.TrimSpace(item.URL) link := strings.TrimSpace(item.URL)
thumb := firstNonEmpty(item.Thumbnail, item.ThumbnailSrc, item.ImgSrc, deriveThumbnail(link)) if link == "" {
continue
}
results = append(results, SearchResult{ results = append(results, SearchResult{
Title: item.Title, Title: item.Title,
Link: link, Link: link,
DisplayLink: inferDisplayLink(link, item.ParsedURL), DisplayLink: inferDisplayLink(link, item.ParsedURL),
Snippet: item.Content, Snippet: item.Content,
ThumbnailURL: thumb, ThumbnailURL: firstNonEmpty(item.Thumbnail, item.ThumbnailSrc, item.ImgSrc, deriveThumbnail(link)),
Source: normalizeSource(source, link, item.Engine), Source: normalizeSource(source, link, item.Engine),
}) })
} }
return results, nil return results, nil
} }
func buildGoogleVideoQuery(query string) string { func buildGoogleVideoQueries(base string) []string {
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR "establishing shot" OR editorial) -tutorial -"how to" -review -reaction -course -podcast -vlog -interview -breakdown -edit -editing`, query) return []string{
fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR "establishing shot" OR editorial) -tutorial -"how to" -review -reaction -course -podcast -vlog -interview -breakdown -edit -editing`, base),
fmt.Sprintf(`"%s" ("cinematic footage" OR "free stock footage" OR "4k footage") -tutorial -"how to" -review`, base),
}
} }
func buildEnvatoQuery(query string) string { func buildEnvatoQueries(base string) []string {
return fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "motion graphics" OR cinematic OR "b-roll") (site:elements.envato.com OR site:videohive.net/item) -site:elements.envato.com/stock-video -site:elements.envato.com/video-templates -site:elements.envato.com/stock-video/stock-footage`, query) return []string{
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com`, base),
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:videohive.net/item`, base),
}
} }
func buildArtgridQuery(query string) string { func buildArtgridQueries(base string) []string {
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, query) return []string{
fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, base),
fmt.Sprintf(`"%s" ("footage" OR "cinematic" OR "establishing shot") site:artgrid.io/clip/`, base),
}
} }
func isUsefulGoogleVideoResult(result SearchResult) bool { func isUsefulGoogleVideoResult(result SearchResult) bool {
@@ -200,21 +299,13 @@ func isUsefulGoogleVideoResult(result SearchResult) bool {
for _, banned := range []string{ for _, banned := range []string{
"tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough", "tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough",
"course", "lesson", "edit tutorial", "editing tutorial", "premiere pro", "after effects", "course", "lesson", "edit tutorial", "editing tutorial", "premiere pro", "after effects",
"breakdown", "explained", "vlog", "breakdown", "explained", "vlog", "tips", "guide", "learn", "free download",
} { } {
if strings.Contains(text, banned) { if strings.Contains(text, banned) {
return false return false
} }
} }
for _, desired := range []string{ return true
"b-roll", "stock footage", "cinematic", "footage", "establishing shot", "4k",
} {
if strings.Contains(text, desired) {
return true
}
}
lowerLink := strings.ToLower(result.Link)
return strings.Contains(lowerLink, "youtube.com/watch") || strings.Contains(lowerLink, "youtu.be/")
} }
func isRenderableEnvatoResult(result SearchResult) bool { func isRenderableEnvatoResult(result SearchResult) bool {
@@ -225,7 +316,7 @@ func isRenderableEnvatoResult(result SearchResult) bool {
host := strings.ToLower(parsed.Host) host := strings.ToLower(parsed.Host)
path := strings.Trim(parsed.Path, "/") path := strings.Trim(parsed.Path, "/")
if strings.Contains(host, "videohive.net") { if strings.Contains(host, "videohive.net") {
return strings.HasPrefix(path, "item/") return strings.HasPrefix(path, "item/") && len(strings.Split(path, "/")) >= 2
} }
if strings.Contains(host, "elements.envato.com") { if strings.Contains(host, "elements.envato.com") {
if path == "" || strings.Contains(path, "/") { if path == "" || strings.Contains(path, "/") {
@@ -244,17 +335,7 @@ func isRenderableArtgridResult(result SearchResult) bool {
if !strings.Contains(strings.ToLower(parsed.Host), "artgrid.io") { if !strings.Contains(strings.ToLower(parsed.Host), "artgrid.io") {
return false return false
} }
path := strings.Trim(parsed.Path, "/") return regexp.MustCompile(`^/clip/[0-9]+/`).MatchString(parsed.Path)
return regexp.MustCompile(`^clip/[0-9]+/`).MatchString(path)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
} }
func normalizeSource(source, link, engine string) string { func normalizeSource(source, link, engine string) string {
@@ -305,6 +386,150 @@ func extractYouTubeID(link string) string {
return "" return ""
} }
func extractMetaContent(html, property string) string {
patterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)<meta[^>]+property=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']+)`),
regexp.MustCompile(`(?i)<meta[^>]+name=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']+)`),
}
for _, pattern := range patterns {
matches := pattern.FindStringSubmatch(html)
if len(matches) == 2 {
return htmlUnescape(matches[1])
}
}
return ""
}
func extractVideoPreviewURL(html string) string {
pattern := regexp.MustCompile(`https?:\\?/\\?/[^"'\\s>]+(?:mp4|m3u8)`)
matches := pattern.FindAllString(html, -1)
for _, match := range matches {
candidate := strings.ReplaceAll(match, `\/`, `/`)
candidate = strings.ReplaceAll(candidate, `\u002F`, `/`)
candidate = strings.ReplaceAll(candidate, `\\`, "")
if strings.Contains(strings.ToLower(candidate), "preview") || strings.Contains(strings.ToLower(candidate), "video") {
return candidate
}
}
return ""
}
func extractArtgridClipID(link string) string {
matches := regexp.MustCompile(`/clip/([0-9]+)/`).FindStringSubmatch(link)
if len(matches) == 2 {
return matches[1]
}
return ""
}
func collectURLs(body string) []string {
pattern := regexp.MustCompile(`https?:\/\/[^"'\\\s]+`)
matches := pattern.FindAllString(body, -1)
seen := map[string]bool{}
results := make([]string, 0, len(matches))
for _, match := range matches {
candidate := strings.TrimSpace(strings.Trim(match, `"'`))
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
results = append(results, candidate)
}
return results
}
func pickImageURL(urls []string) string {
for _, item := range urls {
lower := strings.ToLower(item)
if strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg") || strings.Contains(lower, ".png") || strings.Contains(lower, ".webp") {
return item
}
}
return ""
}
func pickVideoURL(urls []string) string {
for _, item := range urls {
lower := strings.ToLower(item)
if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".m3u8") {
return item
}
}
return ""
}
func (s *SearchService) fetchText(target string) (string, error) {
resp, err := s.Client.Get(target)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("fetch returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return "", err
}
return string(data), nil
}
func (s *SearchService) fetchJSONText(target string) (string, error) {
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json, text/json")
resp, err := s.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("json fetch returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return "", err
}
return string(data), nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func limitQueries(queries []string, limit int) []string {
seen := map[string]bool{}
filtered := make([]string, 0, minInt(len(queries), limit))
for _, item := range queries {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
filtered = append(filtered, trimmed)
if len(filtered) >= limit {
break
}
}
return filtered
}
func htmlUnescape(text string) string {
replacer := strings.NewReplacer("&amp;", "&", "&quot;", `"`, "&#39;", "'", "&lt;", "<", "&gt;", ">")
return replacer.Replace(text)
}
func sourceWeight(source string) int { func sourceWeight(source string) int {
switch source { switch source {
case "Envato": case "Envato":
@@ -317,3 +542,10 @@ func sourceWeight(source string) int {
return 0 return 0
} }
} }
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
+68 -161
View File
@@ -9,6 +9,9 @@ import (
"mime" "mime"
"net/http" "net/http"
neturl "net/url" neturl "net/url"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
) )
@@ -19,12 +22,13 @@ type GeminiService struct {
} }
type AIRecommendation struct { type AIRecommendation struct {
Title string `json:"title"` Title string `json:"title"`
Link string `json:"link"` Link string `json:"link"`
ThumbnailURL string `json:"thumbnailUrl"` ThumbnailURL string `json:"thumbnailUrl"`
Source string `json:"source"` PreviewVideoURL string `json:"previewVideoUrl"`
Reason string `json:"reason"` Source string `json:"source"`
Recommended bool `json:"recommended"` Reason string `json:"reason"`
Recommended bool `json:"recommended"`
} }
type QueryExpansion struct { type QueryExpansion struct {
@@ -39,141 +43,8 @@ func NewGeminiService(apiKey string) *GeminiService {
} }
func (g *GeminiService) ExpandQuery(query string) ([]string, error) { func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
if g.APIKey == "" {
return fallbackQueryExpansion(query, query), nil
}
englishBase := g.TranslateQuery(query) englishBase := g.TranslateQuery(query)
return buildSearchQueries(query, englishBase), nil
body := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
{
"text": "You are a JSON-only API. Output valid JSON only. Never add prose, labels, markdown, or explanations before or after the JSON.",
},
},
},
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": `Return JSON only in this shape: {"querywords":["..."]}.
Generate at most 10 concise English search variations for media discovery across Google Video, Envato, and Artgrid.
The queries must be usable directly in English search engines for stock footage discovery.
Prioritize media, video footage, stock footage, cinematic b-roll, editorial footage, and scene-based search terms.
Avoid celebrity gossip, reaction-style phrasing, clickbait phrasing, and generic web search wording.
Do not output Korean unless it is part of a proper noun.
Original user query: ` + query + `
English base translation: ` + englishBase,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "application/json",
"temperature": 0.2,
"maxOutputTokens": 220,
"responseSchema": map[string]any{
"type": "OBJECT",
"properties": map[string]any{
"querywords": map[string]any{
"type": "ARRAY",
"items": map[string]any{
"type": "STRING",
},
},
},
"required": []string{"querywords"},
},
},
}
rawText, err := g.generateText(body)
if err != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
jsonText, err := extractJSONObject(rawText)
if err != nil {
strictBody := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
{
"text": "You are a strict JSON emitter. Output one valid JSON object only. Do not write any other text.",
},
},
},
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": `STRICT JSON ONLY.
Output must start with { and end with }.
Do not add prose, explanations, markdown, code fences, or labels.
Return exactly this shape: {"querywords":["..."]}.
Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid.
Every query must be in natural English and suitable for stock-footage search.
Original user query: ` + query + `
English base translation: ` + englishBase,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "application/json",
"temperature": 0.1,
"maxOutputTokens": 220,
"responseSchema": map[string]any{
"type": "OBJECT",
"properties": map[string]any{
"querywords": map[string]any{
"type": "ARRAY",
"items": map[string]any{
"type": "STRING",
},
},
},
"required": []string{"querywords"},
},
},
}
rawText, retryErr := g.generateText(strictBody)
if retryErr != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
jsonText, err = extractJSONObject(rawText)
if err != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
}
var parsed QueryExpansion
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
queries := fallbackQueryExpansion(query, englishBase)
seen := map[string]bool{}
englishOnly := !strings.EqualFold(strings.TrimSpace(englishBase), strings.TrimSpace(query))
for _, existing := range queries {
seen[strings.ToLower(strings.TrimSpace(existing))] = true
}
for _, item := range parsed.Querywords {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if englishOnly && !isLikelyEnglishQuery(trimmed) {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
queries = append(queries, trimmed)
}
return queries, nil
} }
func (g *GeminiService) TranslateQuery(query string) string { func (g *GeminiService) TranslateQuery(query string) string {
@@ -277,7 +148,7 @@ User query: ` + query,
maxImages := min(len(candidates), 10) maxImages := min(len(candidates), 10)
for idx := 0; idx < maxImages; idx++ { for idx := 0; idx < maxImages; idx++ {
img, mimeType, err := fetchImageAsInlineData(g.Client, candidates[idx].ThumbnailURL) img, mimeType, err := fetchCandidateVisualInlineData(g.Client, candidates[idx])
if err != nil { if err != nil {
continue continue
} }
@@ -348,24 +219,26 @@ User query: ` + query,
} }
src := candidates[rec.Index] src := candidates[rec.Index]
recommendations = append(recommendations, AIRecommendation{ recommendations = append(recommendations, AIRecommendation{
Title: src.Title, Title: src.Title,
Link: src.Link, Link: src.Link,
ThumbnailURL: src.ThumbnailURL, ThumbnailURL: src.ThumbnailURL,
Source: src.Source, PreviewVideoURL: src.PreviewVideoURL,
Reason: rec.Reason, Source: src.Source,
Recommended: true, Reason: rec.Reason,
Recommended: true,
}) })
} }
if len(recommendations) == 0 { if len(recommendations) == 0 {
for _, candidate := range candidates[:min(4, len(candidates))] { for _, candidate := range candidates[:min(4, len(candidates))] {
recommendations = append(recommendations, AIRecommendation{ recommendations = append(recommendations, AIRecommendation{
Title: candidate.Title, Title: candidate.Title,
Link: candidate.Link, Link: candidate.Link,
ThumbnailURL: candidate.ThumbnailURL, ThumbnailURL: candidate.ThumbnailURL,
Source: candidate.Source, PreviewVideoURL: candidate.PreviewVideoURL,
Reason: "Fallback result because Gemini returned no recommended items.", Source: candidate.Source,
Recommended: true, Reason: "Fallback result because Gemini returned no recommended items.",
Recommended: true,
}) })
} }
} }
@@ -374,6 +247,9 @@ User query: ` + query,
} }
func fetchImageAsInlineData(client *http.Client, imageURL string) (string, string, error) { func fetchImageAsInlineData(client *http.Client, imageURL string) (string, string, error) {
if strings.TrimSpace(imageURL) == "" {
return "", "", fmt.Errorf("image url is empty")
}
resp, err := client.Get(imageURL) resp, err := client.Get(imageURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -397,6 +273,40 @@ func fetchImageAsInlineData(client *http.Client, imageURL string) (string, strin
return base64.StdEncoding.EncodeToString(data), mimeType, nil return base64.StdEncoding.EncodeToString(data), mimeType, nil
} }
func fetchCandidateVisualInlineData(client *http.Client, candidate SearchResult) (string, string, error) {
if candidate.ThumbnailURL != "" {
data, mimeType, err := fetchImageAsInlineData(client, candidate.ThumbnailURL)
if err == nil {
return data, mimeType, nil
}
}
if candidate.PreviewVideoURL != "" {
return extractFrameFromVideo(candidate.PreviewVideoURL)
}
return "", "", fmt.Errorf("candidate has no thumbnail or preview video")
}
func extractFrameFromVideo(videoURL string) (string, string, error) {
tempDir, err := os.MkdirTemp("", "gemini-frame-*")
if err != nil {
return "", "", err
}
defer os.RemoveAll(tempDir)
framePath := filepath.Join(tempDir, "frame.jpg")
cmd := exec.Command("ffmpeg", "-y", "-ss", "00:00:00.500", "-i", videoURL, "-frames:v", "1", "-q:v", "2", framePath)
output, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("ffmpeg frame extraction failed: %s", strings.TrimSpace(string(output)))
}
data, err := os.ReadFile(framePath)
if err != nil {
return "", "", err
}
return base64.StdEncoding.EncodeToString(data), "image/jpeg", nil
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
@@ -457,27 +367,24 @@ func truncateForError(text string, limit int) string {
return trimmed[:limit] + "..." return trimmed[:limit] + "..."
} }
func fallbackQueryExpansion(originalQuery, englishQuery string) []string { func buildSearchQueries(originalQuery, englishQuery string) []string {
base := strings.TrimSpace(englishQuery) base := strings.TrimSpace(englishQuery)
if base == "" { if base == "" {
base = strings.TrimSpace(originalQuery) base = strings.TrimSpace(originalQuery)
} }
candidates := []string{ candidates := []string{
base, base,
base + " b-roll", strings.ReplaceAll(base, "pov", "point of view"),
base + " stock footage", base + " stock footage",
base + " b-roll",
base + " cinematic footage", base + " cinematic footage",
base + " establishing shot",
base + " editorial footage", base + " editorial footage",
base + " urban scene", base + " establishing shot",
base + " ambient footage",
base + " 4k footage",
base + " cinematic b-roll",
} }
seen := map[string]bool{} seen := map[string]bool{}
queries := make([]string, 0, len(candidates)) queries := make([]string, 0, len(candidates))
for _, item := range candidates { for _, item := range candidates {
trimmed := strings.TrimSpace(item) trimmed := strings.TrimSpace(strings.Join(strings.Fields(item), " "))
if trimmed == "" { if trimmed == "" {
continue continue
} }
+17 -2
View File
@@ -133,12 +133,27 @@ function renderResults(results) {
} }
for (const item of results) { for (const item of results) {
const node = cardTemplate.content.firstElementChild.cloneNode(true); const node = cardTemplate.content.firstElementChild.cloneNode(true);
const image = node.querySelector("img");
const previewVideo = node.querySelector(".preview-hover");
node.href = item.link; node.href = item.link;
node.querySelector("img").src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=No+Preview"; image.src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
node.querySelector("img").alt = item.title; image.alt = item.title;
node.querySelector("h3").textContent = item.title; node.querySelector("h3").textContent = item.title;
node.querySelector("p").textContent = item.reason; node.querySelector("p").textContent = item.reason;
node.querySelector(".source-badge").textContent = item.source; node.querySelector(".source-badge").textContent = item.source;
if (item.previewVideoUrl) {
previewVideo.src = item.previewVideoUrl;
previewVideo.poster = item.thumbnailUrl || "";
node.addEventListener("mouseenter", () => {
previewVideo.classList.remove("hidden");
previewVideo.play().catch(() => {});
});
node.addEventListener("mouseleave", () => {
previewVideo.pause();
previewVideo.currentTime = 0;
previewVideo.classList.add("hidden");
});
}
searchResults.appendChild(node); searchResults.appendChild(node);
} }
} }
+2 -1
View File
@@ -128,6 +128,7 @@
<a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30"> <a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30">
<div class="relative aspect-video overflow-hidden bg-zinc-900"> <div class="relative aspect-video overflow-hidden bg-zinc-900">
<img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" /> <img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" />
<video class="preview-hover absolute inset-0 hidden h-full w-full object-cover" muted loop playsinline preload="none"></video>
<div class="absolute left-3 top-3 rounded-full border border-white/20 bg-black/60 px-3 py-1 text-[11px] uppercase tracking-[0.25em] text-white">AI Recommended</div> <div class="absolute left-3 top-3 rounded-full border border-white/20 bg-black/60 px-3 py-1 text-[11px] uppercase tracking-[0.25em] text-white">AI Recommended</div>
<div class="source-badge absolute bottom-3 left-3 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-black"></div> <div class="source-badge absolute bottom-3 left-3 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-black"></div>
</div> </div>
@@ -138,6 +139,6 @@
</a> </a>
</template> </template>
<script src="/app.js?v=20260313d" defer></script> <script src="/app.js?v=20260313e" defer></script>
</body> </body>
</html> </html>