Improve translated search flow and modal layout
build-push / docker (push) Successful in 4m19s

This commit is contained in:
AI Assistant
2026-03-13 11:57:58 +09:00
parent 1fc06fb785
commit 6852e07607
4 changed files with 186 additions and 70 deletions
+6 -4
View File
@@ -348,7 +348,9 @@ func rankSearchResults(query string, results []services.SearchResult) []services
}
negativeTerms := []string{
"shocking", "amazing", "crazy", "must watch", "reaction", "gossip", "celebrity",
"thumbnail", "meme", "prank", "drama", "breaking", "viral",
"thumbnail", "meme", "prank", "drama", "breaking", "viral", "tutorial",
"how to", "review", "walkthrough", "course", "lesson", "podcast", "interview",
"premiere pro", "after effects", "explained", "breakdown", "vlog",
}
type scoredResult struct {
item services.SearchResult
@@ -379,11 +381,11 @@ func rankSearchResults(query string, results []services.SearchResult) []services
}
switch result.Source {
case "Google Video":
score += 3
score += 2
case "Envato":
score += 4
score += 5
case "Artgrid":
score += 4
score += 5
}
scored = append(scored, scoredResult{item: result, score: score})
}
+73 -47
View File
@@ -52,30 +52,30 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
categories string
engine string
queryBuilder func(string) string
match func(string) bool
match func(SearchResult) bool
}{
{
name: "Google Video",
categories: "videos",
engine: s.GoogleVideoEngine,
queryBuilder: func(query string) string {
return query
return buildGoogleVideoQuery(query)
},
match: func(string) bool { return true },
match: isUsefulGoogleVideoResult,
},
{
name: "Envato",
categories: "general",
engine: s.WebEngine,
queryBuilder: buildEnvatoQuery,
match: isEnvatoURL,
match: isRenderableEnvatoResult,
},
{
name: "Artgrid",
categories: "general",
engine: s.WebEngine,
queryBuilder: buildArtgridQuery,
match: func(link string) bool { return strings.Contains(strings.ToLower(link), "artgrid.io") },
match: isRenderableArtgridResult,
},
}
@@ -88,10 +88,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
continue
}
for _, source := range sources {
searchQuery := query
if source.queryBuilder != nil {
searchQuery = source.queryBuilder(query)
}
searchQuery := source.queryBuilder(query)
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
if err != nil {
@@ -112,10 +109,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
if item.Link == "" || seen[item.Link] {
continue
}
if source.match != nil && !source.match(item.Link) {
continue
}
if !isRenderableLink(item.Link, item.Source) {
if !source.match(item) {
continue
}
seen[item.Link] = true
@@ -138,7 +132,7 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
values.Set("q", query)
values.Set("format", "json")
values.Set("safesearch", "0")
values.Set("language", "ko-KR")
values.Set("language", "en-US")
if categories != "" {
values.Set("categories", categories)
}
@@ -189,6 +183,71 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
return results, nil
}
func buildGoogleVideoQuery(query 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)
}
func buildEnvatoQuery(query 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)
}
func buildArtgridQuery(query string) string {
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, query)
}
func isUsefulGoogleVideoResult(result SearchResult) bool {
text := strings.ToLower(result.Title + " " + result.Snippet)
for _, banned := range []string{
"tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough",
"course", "lesson", "edit tutorial", "editing tutorial", "premiere pro", "after effects",
"breakdown", "explained", "vlog",
} {
if strings.Contains(text, banned) {
return false
}
}
for _, desired := range []string{
"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 {
parsed, err := url.Parse(result.Link)
if err != nil {
return false
}
host := strings.ToLower(parsed.Host)
path := strings.Trim(parsed.Path, "/")
if strings.Contains(host, "videohive.net") {
return strings.HasPrefix(path, "item/")
}
if strings.Contains(host, "elements.envato.com") {
if path == "" || strings.Contains(path, "/") {
return false
}
return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path)
}
return false
}
func isRenderableArtgridResult(result SearchResult) bool {
parsed, err := url.Parse(result.Link)
if err != nil {
return false
}
if !strings.Contains(strings.ToLower(parsed.Host), "artgrid.io") {
return false
}
path := strings.Trim(parsed.Path, "/")
return regexp.MustCompile(`^clip/[0-9]+/`).MatchString(path)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -225,39 +284,6 @@ func inferDisplayLink(link string, parsed []any) string {
return ""
}
func isEnvatoURL(link string) bool {
lower := strings.ToLower(link)
return strings.Contains(lower, "envato") || strings.Contains(lower, "videohive.net")
}
func isRenderableLink(link, source string) bool {
parsed, err := url.Parse(link)
if err != nil {
return false
}
path := strings.Trim(parsed.Path, "/")
if path == "" {
return false
}
lower := strings.ToLower(link)
switch source {
case "Envato":
return strings.Contains(lower, "/item/") || strings.Contains(lower, "/stock-video/") || strings.Contains(lower, "/video-templates/")
case "Artgrid":
return strings.Contains(lower, "artgrid.io") && len(strings.Split(path, "/")) >= 2
default:
return true
}
}
func buildEnvatoQuery(query string) string {
return fmt.Sprintf(`%s ("stock video" OR footage OR "video template" OR cinematic) (site:elements.envato.com/stock-video OR site:elements.envato.com/video-templates OR site:videohive.net/item)`, query)
}
func buildArtgridQuery(query string) string {
return fmt.Sprintf(`%s ("stock footage" OR "b-roll" OR cinematic OR editorial) (site:artgrid.io)`, query)
}
func deriveThumbnail(link string) string {
if videoID := extractYouTubeID(link); videoID != "" {
return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg"
+100 -14
View File
@@ -39,9 +39,11 @@ func NewGeminiService(apiKey string) *GeminiService {
func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
if g.APIKey == "" {
return fallbackQueryExpansion(query), nil
return fallbackQueryExpansion(query, query), nil
}
englishBase := g.TranslateQuery(query)
body := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
@@ -55,12 +57,13 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
"parts": []map[string]string{
{
"text": `Return JSON only in this shape: {"querywords":["..."]}.
Generate at most 10 concise search variations for media discovery across Google Video, Envato, and Artgrid.
If the user query is in Korean, include strong English search variants that a stock footage editor would use.
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.
Mix Korean and English when useful, but make sure several queries are clean English production keywords.
User query: ` + query,
Do not output Korean unless it is part of a proper noun.
Original user query: ` + query + `
English base translation: ` + englishBase,
},
},
},
@@ -86,7 +89,7 @@ User query: ` + query,
rawText, err := g.generateText(body)
if err != nil {
return fallbackQueryExpansion(query), nil
return fallbackQueryExpansion(query, englishBase), nil
}
jsonText, err := extractJSONObject(rawText)
@@ -108,8 +111,9 @@ 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.
If the original query is Korean, include strong English stock-footage search phrases.
User query: ` + query,
Every query must be in natural English and suitable for stock-footage search.
Original user query: ` + query + `
English base translation: ` + englishBase,
},
},
},
@@ -134,20 +138,20 @@ User query: ` + query,
}
rawText, retryErr := g.generateText(strictBody)
if retryErr != nil {
return fallbackQueryExpansion(query), nil
return fallbackQueryExpansion(query, englishBase), nil
}
jsonText, err = extractJSONObject(rawText)
if err != nil {
return fallbackQueryExpansion(query), nil
return fallbackQueryExpansion(query, englishBase), nil
}
}
var parsed QueryExpansion
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
return fallbackQueryExpansion(query), nil
return fallbackQueryExpansion(query, englishBase), nil
}
queries := fallbackQueryExpansion(query)
queries := fallbackQueryExpansion(query, englishBase)
seen := map[string]bool{}
for _, existing := range queries {
seen[strings.ToLower(strings.TrimSpace(existing))] = true
@@ -167,6 +171,47 @@ User query: ` + query,
return queries, nil
}
func (g *GeminiService) TranslateQuery(query string) string {
if strings.TrimSpace(query) == "" || looksMostlyASCII(query) || g.APIKey == "" {
return strings.TrimSpace(query)
}
body := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
{
"text": "You translate media search intents into natural English. Output one plain English search phrase only. No labels, no quotes, no explanations.",
},
},
},
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": "Translate this user query into concise English suitable for stock-footage search: " + query,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "text/plain",
"temperature": 0.1,
"maxOutputTokens": 40,
},
}
rawText, err := g.generateText(body)
if err != nil {
return strings.TrimSpace(query)
}
translated := sanitizePlainEnglishLine(rawText)
if translated == "" {
return strings.TrimSpace(query)
}
return translated
}
func (g *GeminiService) generateText(body map[string]any) (string, error) {
rawBody, _ := json.Marshal(body)
endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey
@@ -402,8 +447,11 @@ func truncateForError(text string, limit int) string {
return trimmed[:limit] + "..."
}
func fallbackQueryExpansion(query string) []string {
base := strings.TrimSpace(query)
func fallbackQueryExpansion(originalQuery, englishQuery string) []string {
base := strings.TrimSpace(englishQuery)
if base == "" {
base = strings.TrimSpace(originalQuery)
}
candidates := []string{
base,
base + " b-roll",
@@ -416,6 +464,9 @@ func fallbackQueryExpansion(query string) []string {
base + " 4k footage",
base + " cinematic b-roll",
}
if strings.TrimSpace(originalQuery) != "" && !strings.EqualFold(strings.TrimSpace(originalQuery), strings.TrimSpace(englishQuery)) {
candidates = append(candidates, strings.TrimSpace(originalQuery))
}
seen := map[string]bool{}
queries := make([]string, 0, len(candidates))
@@ -433,3 +484,38 @@ func fallbackQueryExpansion(query string) []string {
}
return queries
}
func sanitizePlainEnglishLine(text string) string {
lines := strings.Split(text, "\n")
for _, line := range lines {
line = strings.TrimSpace(strings.Trim(line, "\"'`"))
if line == "" {
continue
}
lower := strings.ToLower(line)
for _, prefix := range []string{"translation:", "english:", "translated query:"} {
if strings.HasPrefix(lower, prefix) {
line = strings.TrimSpace(line[len(prefix):])
lower = strings.ToLower(line)
}
}
if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "the translation") {
continue
}
if line != "" {
return line
}
}
return ""
}
func looksMostlyASCII(text string) bool {
ascii := 0
runes := []rune(text)
for _, r := range runes {
if r <= 127 {
ascii++
}
}
return ascii >= len(runes)*8/10
}
+7 -5
View File
@@ -70,7 +70,7 @@
</main>
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
<div class="w-full max-w-4xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
<div class="w-full max-w-5xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Download Preview</p>
@@ -78,12 +78,12 @@
</div>
<button id="closePreviewModal" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
</div>
<div class="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_360px]">
<div class="mt-5 flex flex-col gap-5">
<div id="previewMediaFrame" class="min-w-0 flex min-h-[320px] items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black/30 p-2">
<video id="previewVideo" class="hidden max-h-[60vh] w-full bg-black object-contain" controls playsinline></video>
<img id="previewThumbnail" class="max-h-[60vh] w-full object-contain" alt="" />
</div>
<div class="min-w-0 space-y-4">
<div class="min-w-0 grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div class="flex items-center justify-between text-sm text-zinc-400">
<span>Detected duration</span>
@@ -116,7 +116,9 @@
<span class="text-sm text-zinc-400">Quality</span>
<select id="qualitySelect" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"></select>
</label>
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button>
<div class="lg:col-span-2">
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button>
</div>
</div>
</div>
</div>
@@ -136,6 +138,6 @@
</a>
</template>
<script src="/app.js?v=20260313c" defer></script>
<script src="/app.js?v=20260313d" defer></script>
</body>
</html>